Skip to content

Commit 0b813f4

Browse files
i99devclaude
andcommitted
fix(privacy): stop Android backup restoring optional-service consent
`android:allowBackup` was never declared, so it defaulted to true. On an emulator, `adb uninstall` followed by `adb install` of the v3.22.2-b release skipped onboarding entirely and every optional service — catalogs, radio/TV streaming and GitHub update checks — came back already enabled, because Android restored the previous install's preferences. That makes three statements untrue: the Optional Services screen ("off until you enable them"), docs/ORIENTATION.md ("a fresh install starts with everything off") and the release notes ("every outbound connection sits behind a switch that ships off"). A driver who reinstalls, or restores onto a new head unit, gets streaming and update checking without being asked. It also explains the runtime error seen on that first launch: flutter_secure_storage ciphertext was restored without its Android Keystore key, which never leaves the device — "Key mismatch detected during cipher initialization". Backup is now off on both paths. `allowBackup="false"` covers cloud backup on every API level; res/xml/data_extraction_rules.xml is required as well because on API 31+ device-to-device transfer is governed there, not by allowBackup. Verified in the built APK: allowBackup=false and dataExtractionRules resolving to a compiled resource. Trade-off, taken deliberately: favorites, themes and settings no longer migrate to a new device. They live in the same FlutterSharedPreferences file as `optional_services.v1` and Android's backup rules are file-granular, so consent cannot be excluded on its own. Silently inheriting consent is the worse outcome for an app that can actuate a vehicle. Keeping migration would mean moving the consent key into its own preferences file. test/architecture/backup_disabled_test.dart guards it: the attribute defaults to true when absent, so deleting the line is a silent regression rather than a build failure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AjhsQbfGjJw2V8j2WmRXpN
1 parent edd2489 commit 0b813f4

3 files changed

Lines changed: 149 additions & 0 deletions

File tree

android/app/src/main/AndroidManifest.xml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,11 +153,22 @@
153153
<uses-permission android:name="android.permission.READ_LOGS"
154154
tools:ignore="ProtectedPermissions"/>
155155
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
156+
<!-- Backup is OFF on purpose. Restoring a previous install's preferences
157+
carries optional-service CONSENT (catalogs, streaming, GitHub
158+
updates) onto the new install, so the driver is never shown the
159+
opt-in the UI promises ("off until you enable them"). It also
160+
restores flutter_secure_storage ciphertext whose Keystore key is
161+
gone, which fails at runtime with "Key mismatch detected".
162+
allowBackup covers cloud backup on every API level;
163+
@xml/data_extraction_rules is needed as well because API 31+ governs
164+
device-to-device transfer there. -->
156165
<application
157166
android:label="iLINK"
158167
android:name="${applicationName}"
159168
android:icon="@mipmap/ic_launcher"
160169
android:largeHeap="true"
170+
android:allowBackup="false"
171+
android:dataExtractionRules="@xml/data_extraction_rules"
161172
android:networkSecurityConfig="@xml/network_security_config">
162173
<!-- launchMode + taskAffinity tuning for chooser-delivered
163174
intents.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!--
3+
Android 12+ (API 31+) backup/transfer policy.
4+
5+
Nothing this app stores may leave the device through Backup & Restore or
6+
device-to-device transfer. Two reasons, both load-bearing:
7+
8+
1. Optional-service consent (`optional_services.v1` in
9+
FlutterSharedPreferences) records whether the driver allowed catalogs,
10+
radio/TV streaming and GitHub update checks. Those default to OFF and
11+
the UI promises they are "off until you enable them". A restored
12+
preferences file silently carries a previous grant onto a new install,
13+
so the driver is never asked and the promise is broken.
14+
15+
2. flutter_secure_storage ciphertext is bound to an Android Keystore key
16+
that is destroyed on uninstall and is NEVER part of a backup. Restoring
17+
the ciphertext alone produces unreadable values and the runtime error
18+
"Key mismatch detected during cipher initialization".
19+
20+
`android:allowBackup="false"` in AndroidManifest.xml covers cloud backup on
21+
every API level; this file is required as well because on API 31+ the
22+
device-transfer path is governed here rather than by allowBackup.
23+
24+
Consequence, accepted deliberately: favorites, themes and settings do not
25+
migrate to a new device. Consent state is not something to silently inherit
26+
in an app that can actuate a vehicle.
27+
-->
28+
<data-extraction-rules>
29+
<cloud-backup>
30+
<exclude domain="root" />
31+
<exclude domain="file" />
32+
<exclude domain="database" />
33+
<exclude domain="sharedpref" />
34+
<exclude domain="external" />
35+
<exclude domain="device_root" />
36+
<exclude domain="device_file" />
37+
<exclude domain="device_database" />
38+
<exclude domain="device_sharedpref" />
39+
</cloud-backup>
40+
<device-transfer>
41+
<exclude domain="root" />
42+
<exclude domain="file" />
43+
<exclude domain="database" />
44+
<exclude domain="sharedpref" />
45+
<exclude domain="external" />
46+
<exclude domain="device_root" />
47+
<exclude domain="device_file" />
48+
<exclude domain="device_database" />
49+
<exclude domain="device_sharedpref" />
50+
</device-transfer>
51+
</data-extraction-rules>
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import 'dart:io';
2+
3+
import 'package:flutter_test/flutter_test.dart';
4+
5+
/// CI gate: Android backup must stay disabled.
6+
///
7+
/// `android:allowBackup` defaults to **true** when the attribute is absent, so
8+
/// deleting the line is a silent regression rather than a build error. What it
9+
/// costs when it regresses:
10+
///
11+
/// * Optional-service consent (`optional_services.v1`) is restored from a
12+
/// previous install, so catalogs / streaming / GitHub updates come back ON
13+
/// without the driver being asked. The Optional Services screen states they
14+
/// are "off until you enable them"; a restore makes that untrue.
15+
/// * flutter_secure_storage ciphertext is restored without its Keystore key
16+
/// (the key never leaves the device), producing the runtime error
17+
/// "Key mismatch detected during cipher initialization".
18+
///
19+
/// Observed on an emulator: `adb uninstall` followed by `adb install` skipped
20+
/// onboarding entirely and every optional service was already enabled.
21+
void main() {
22+
group('Android backup policy', () {
23+
final manifest = File('android/app/src/main/AndroidManifest.xml');
24+
25+
test('manifest exists where this test expects it', () {
26+
expect(
27+
manifest.existsSync(),
28+
isTrue,
29+
reason: 'Run tests from the repository root.',
30+
);
31+
});
32+
33+
test('allowBackup is explicitly false', () {
34+
final xml = manifest.readAsStringSync();
35+
expect(
36+
xml.contains('android:allowBackup="false"'),
37+
isTrue,
38+
reason:
39+
'android:allowBackup must be explicitly false — the attribute '
40+
'defaults to true when omitted, which restores optional-service '
41+
'consent onto a fresh install.',
42+
);
43+
expect(
44+
xml.contains('android:allowBackup="true"'),
45+
isFalse,
46+
reason: 'Backup must not be re-enabled.',
47+
);
48+
});
49+
50+
test('device-to-device transfer is governed by extraction rules', () {
51+
// On API 31+ allowBackup alone does not stop D2D transfer.
52+
final xml = manifest.readAsStringSync();
53+
expect(
54+
xml.contains(
55+
'android:dataExtractionRules="@xml/data_extraction_rules"',
56+
),
57+
isTrue,
58+
reason:
59+
'API 31+ governs device-to-device transfer through '
60+
'dataExtractionRules, not allowBackup.',
61+
);
62+
});
63+
64+
test('extraction rules exclude every domain from both paths', () {
65+
final rules = File(
66+
'android/app/src/main/res/xml/data_extraction_rules.xml',
67+
);
68+
expect(rules.existsSync(), isTrue);
69+
final xml = rules.readAsStringSync();
70+
71+
expect(xml.contains('<cloud-backup>'), isTrue);
72+
expect(xml.contains('<device-transfer>'), isTrue);
73+
74+
// Both sections must exclude the domains that can hold consent or
75+
// secure-storage ciphertext. Each appears once per section.
76+
for (final domain in ['root', 'file', 'database', 'sharedpref']) {
77+
expect(
78+
'<exclude domain="$domain" />'.allMatches(xml).length,
79+
2,
80+
reason:
81+
'domain "$domain" must be excluded from BOTH cloud-backup and '
82+
'device-transfer.',
83+
);
84+
}
85+
});
86+
});
87+
}

0 commit comments

Comments
 (0)