feat(app,ui): sync waits on mobile data and asks first

The developer's request of 10 Nov 2026, decided in ADR 0042: on mobile
data every app holds its sync back, rows and files alike, and asks.

halkyon_app becomes an Android plugin, its first native code, registered
in every app by Flutter: HalkyonAppPlugin.java says which network the
device is on and whether it is metered as Android has it (no
NET_CAPABILITY_NOT_METERED: cellular, a phone's hotspot, Wi-Fi marked
metered). Java, so it applies no Kotlin Gradle Plugin.

MobileDataGate, one per device, holds the setting (Ask first, Sync
without files, Sync everything; kept in <app support>/mobile_data.json),
the network, and the person's answer, which holds until the device moves
to another network or off a metered one. SyncService asks it before each
sync: held back, nothing goes, the status counts what waits and asks; an
answer or Wi-Fi syncs at once, and syncing now while held back is Sync
now. Each app's frame shows the banner above every tab, in halkyon_ui's
new NavigationFrame.banner slot (On mobile data · 3 changes waiting, with
Not now, Without files and Sync now); the Sync card in Settings chooses
the setting on a phone and says when files wait for Wi-Fi. Learn's files
neither go up nor come down while held back.

Whats-New: On mobile data the app no longer syncs by itself: a banner asks first, and Settings › Sync chooses whether to ask, sync without files, or sync everything.
Whats-New-De: Mit mobilen Daten synchronisiert die App nicht mehr von selbst: Ein Hinweis fragt zuerst, und unter Einstellungen › Synchronisierung wählst du, ob sie fragt, ohne Dateien oder alles synchronisiert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-10 23:38:17 +01:00
commit 59fb9c4d6d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -18,6 +18,9 @@ void main() {
late MemoryAssetFileCache cache;
late StreamController<void> triggers;
late AssetRepository repository;
/// Whether files may move now: not on mobile data held back (ADR 0042).
late bool filesAllowed;
final image = Uint8List.fromList(utf8.encode('an image'));
final imageHash = sha256Of(image);
@ -26,12 +29,14 @@ void main() {
server = FakeAssetServerGateway();
cache = MemoryAssetFileCache();
triggers = StreamController<void>.broadcast();
filesAllowed = true;
repository = AssetRepository(
rows,
server,
cache,
FakeAssetSealing(),
uploadTriggers: triggers.stream,
filesAllowed: () => filesAllowed,
);
});
tearDown(() async {
@ -159,6 +164,22 @@ void main() {
expect(cache.pending, isEmpty);
});
test('waits while files are held back on mobile data, and goes once '
'they may (ADR 0042)', () async {
await addImage();
filesAllowed = false;
triggers.add(null);
await pumpEventQueue();
expect(server.started, isEmpty);
expect(cache.pending, {imageHash});
filesAllowed = true;
triggers.add(null);
await pumpEventQueue();
expect(server.files, {imageHash: image});
});
test('sends nothing the server has, and stops waiting', () async {
server.files[imageHash] = image;
await addImage();
@ -291,6 +312,20 @@ void main() {
expect(server.downloads, ['remote-image']);
});
test('downloads nothing while files are held back on mobile data; one '
'this device has is served (ADR 0042)', () async {
imageFromElsewhere();
filesAllowed = false;
expect(await repository.fileOf('remote-image'), isNull);
expect(server.downloads, isEmpty);
filesAllowed = true;
expect((await repository.fileOf('remote-image'))?.bytes, image);
filesAllowed = false;
expect((await repository.fileOf('remote-image'))?.bytes, image);
});
test('downloads a file once when it is asked for twice at once', () async {
imageFromElsewhere();