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>
546 lines
15 KiB
Dart
546 lines
15 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:clock/clock.dart';
|
|
import 'package:fake_async/fake_async.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:halkyon_app/halkyon_app.dart';
|
|
import 'package:halkyon_app/testing.dart';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
late int syncs;
|
|
late Exception? failWith;
|
|
late VoidCallback? merge;
|
|
// While set and not completed, a sync keeps running.
|
|
late Completer<void>? hold;
|
|
|
|
SyncService service({
|
|
PendingChangesStore? pendingChanges,
|
|
bool syncedBefore = false,
|
|
MobileDataGate? mobileData,
|
|
}) => SyncService(
|
|
({required onMerge}) async {
|
|
syncs++;
|
|
merge = onMerge;
|
|
await hold?.future;
|
|
if (failWith case final error?) {
|
|
throw error;
|
|
}
|
|
},
|
|
pendingChanges: pendingChanges,
|
|
syncedBefore: syncedBefore,
|
|
mobileData: mobileData,
|
|
);
|
|
|
|
setUp(() {
|
|
syncs = 0;
|
|
failWith = null;
|
|
merge = null;
|
|
hold = null;
|
|
});
|
|
|
|
test('syncs once at start', () {
|
|
fakeAsync((async) {
|
|
final sync = service()..start();
|
|
async.flushMicrotasks();
|
|
|
|
expect(syncs, 1);
|
|
sync.dispose();
|
|
});
|
|
});
|
|
|
|
test('syncs once after a burst of local changes has settled', () {
|
|
fakeAsync((async) {
|
|
final sync = service()..start();
|
|
async.flushMicrotasks();
|
|
|
|
sync
|
|
..requestSync()
|
|
..requestSync();
|
|
async.elapse(const Duration(seconds: 1));
|
|
sync.requestSync();
|
|
async.elapse(const Duration(seconds: 1));
|
|
expect(syncs, 1, reason: 'still within the debounce');
|
|
|
|
async.elapse(const Duration(seconds: 1));
|
|
expect(syncs, 2);
|
|
sync.dispose();
|
|
});
|
|
});
|
|
|
|
test('syncs every five minutes without other triggers', () {
|
|
fakeAsync((async) {
|
|
final sync = service()..start();
|
|
async.elapse(const Duration(minutes: 10));
|
|
|
|
expect(syncs, 3);
|
|
sync.dispose();
|
|
});
|
|
});
|
|
|
|
test('keeps syncing after a failed sync', () {
|
|
fakeAsync((async) {
|
|
failWith = Exception('No connection');
|
|
final sync = service()..start();
|
|
async.flushMicrotasks();
|
|
|
|
failWith = null;
|
|
sync.requestSync();
|
|
async.elapse(const Duration(seconds: 2));
|
|
|
|
expect(syncs, 2);
|
|
sync.dispose();
|
|
});
|
|
});
|
|
|
|
test('reports changes from other devices', () {
|
|
fakeAsync((async) {
|
|
final sync = service();
|
|
var merges = 0;
|
|
sync.merges.listen((_) => merges++);
|
|
sync.start();
|
|
async.flushMicrotasks();
|
|
|
|
merge!();
|
|
async.flushMicrotasks();
|
|
|
|
expect(merges, 1);
|
|
sync.dispose();
|
|
});
|
|
});
|
|
|
|
test('a trigger during a sync runs one more sync afterwards', () {
|
|
fakeAsync((async) {
|
|
final running = hold = Completer<void>();
|
|
final sync = service()..start();
|
|
async.flushMicrotasks();
|
|
|
|
sync.requestSync();
|
|
async.elapse(const Duration(seconds: 2));
|
|
sync.requestSync();
|
|
async.elapse(const Duration(seconds: 2));
|
|
expect(syncs, 1, reason: 'the first sync is still running');
|
|
|
|
running.complete();
|
|
async.flushMicrotasks();
|
|
expect(syncs, 2);
|
|
sync.dispose();
|
|
});
|
|
});
|
|
|
|
test('lets a running sync finish before it stops', () {
|
|
fakeAsync((async) {
|
|
final running = hold = Completer<void>();
|
|
final sync = service()..start();
|
|
async.flushMicrotasks();
|
|
sync.requestSync();
|
|
async.elapse(const Duration(seconds: 2));
|
|
|
|
var stopped = false;
|
|
unawaited(sync.dispose().then((_) => stopped = true));
|
|
async.flushMicrotasks();
|
|
expect(stopped, isFalse, reason: 'the database is still in use');
|
|
|
|
running.complete();
|
|
async.flushMicrotasks();
|
|
expect(stopped, isTrue);
|
|
expect(syncs, 1, reason: 'no sync starts once it is stopping');
|
|
});
|
|
});
|
|
|
|
test('stops syncing when disposed', () {
|
|
fakeAsync((async) {
|
|
final sync = service()..start();
|
|
async.flushMicrotasks();
|
|
sync
|
|
..requestSync()
|
|
..dispose();
|
|
|
|
async.elapse(const Duration(minutes: 10));
|
|
expect(syncs, 1);
|
|
});
|
|
});
|
|
|
|
group('status', () {
|
|
final noon = DateTime.utc(2026, 10, 3, 12);
|
|
|
|
test('nothing has synced before the first sync', () {
|
|
expect(service().status, const SyncStatus());
|
|
expect(service().status.hasTrouble, isFalse);
|
|
});
|
|
|
|
test('says while a sync runs, and when it went through', () {
|
|
fakeAsync((async) {
|
|
withClock(async.getClock(noon), () {
|
|
final running = hold = Completer<void>();
|
|
final sync = service()..start();
|
|
async.flushMicrotasks();
|
|
expect(sync.status, const SyncStatus(syncing: true));
|
|
|
|
async.elapse(const Duration(seconds: 3));
|
|
running.complete();
|
|
async.flushMicrotasks();
|
|
expect(
|
|
sync.status,
|
|
SyncStatus(
|
|
lastSyncedAt: noon.add(const Duration(seconds: 3)),
|
|
syncedHere: true,
|
|
),
|
|
);
|
|
sync.dispose();
|
|
});
|
|
});
|
|
});
|
|
|
|
test('a failed sync is trouble until a sync goes through again', () {
|
|
fakeAsync((async) {
|
|
withClock(async.getClock(noon), () {
|
|
final sync = service()..start();
|
|
async.flushMicrotasks();
|
|
|
|
failWith = Exception('No connection');
|
|
async.elapse(const Duration(minutes: 5));
|
|
expect(
|
|
sync.status,
|
|
SyncStatus(
|
|
lastSyncedAt: noon,
|
|
failure: (
|
|
at: noon.add(const Duration(minutes: 5)),
|
|
appMismatch: false,
|
|
),
|
|
syncedHere: true,
|
|
),
|
|
);
|
|
expect(sync.status.hasTrouble, isTrue);
|
|
|
|
failWith = null;
|
|
async.elapse(const Duration(minutes: 5));
|
|
expect(
|
|
sync.status,
|
|
SyncStatus(
|
|
lastSyncedAt: noon.add(const Duration(minutes: 10)),
|
|
syncedHere: true,
|
|
),
|
|
);
|
|
sync.dispose();
|
|
});
|
|
});
|
|
});
|
|
|
|
test('still shows the failure while the next sync runs', () {
|
|
fakeAsync((async) {
|
|
withClock(async.getClock(noon), () {
|
|
failWith = Exception('No connection');
|
|
final sync = service()..start();
|
|
async.flushMicrotasks();
|
|
|
|
hold = Completer<void>();
|
|
sync.syncNow();
|
|
async.flushMicrotasks();
|
|
expect(sync.status.syncing, isTrue);
|
|
expect(sync.status.hasTrouble, isTrue);
|
|
hold!.complete();
|
|
sync.dispose();
|
|
});
|
|
});
|
|
});
|
|
|
|
test("tells when this app and the server don't match", () {
|
|
fakeAsync((async) {
|
|
withClock(async.getClock(noon), () {
|
|
failWith = const AppServerMismatchException();
|
|
final sync = service()..start();
|
|
async.flushMicrotasks();
|
|
|
|
expect(sync.status.appMismatch, isTrue);
|
|
expect(sync.status.failure, (at: noon, appMismatch: true));
|
|
sync.dispose();
|
|
});
|
|
});
|
|
});
|
|
|
|
test('reports every change', () {
|
|
fakeAsync((async) {
|
|
final sync = service();
|
|
final seen = <SyncStatus>[];
|
|
sync.statusChanges.listen(seen.add);
|
|
sync.start();
|
|
async.flushMicrotasks();
|
|
|
|
expect(seen.map((status) => status.syncing), [true, false]);
|
|
sync.dispose();
|
|
});
|
|
});
|
|
});
|
|
|
|
test('syncs at once when the person asks', () {
|
|
fakeAsync((async) {
|
|
final sync = service()..start();
|
|
async.flushMicrotasks();
|
|
|
|
sync.syncNow();
|
|
async.flushMicrotasks();
|
|
|
|
expect(syncs, 2, reason: 'no waiting for changes to settle');
|
|
sync.dispose();
|
|
});
|
|
});
|
|
|
|
group('changes not on the server yet', () {
|
|
test('are the saves since the last sync that went through', () {
|
|
fakeAsync((async) {
|
|
final sync = service()..start();
|
|
async.flushMicrotasks();
|
|
|
|
sync
|
|
..requestSync()
|
|
..requestSync();
|
|
expect(sync.status.pending, 2);
|
|
|
|
async.elapse(const Duration(seconds: 2));
|
|
expect(sync.status.pending, 0);
|
|
sync.dispose();
|
|
});
|
|
});
|
|
|
|
test('stay while syncs fail', () {
|
|
fakeAsync((async) {
|
|
failWith = Exception('No connection');
|
|
final sync = service()..start();
|
|
async.flushMicrotasks();
|
|
|
|
sync.requestSync();
|
|
async.elapse(const Duration(seconds: 2));
|
|
expect(sync.status.pending, 1);
|
|
expect(sync.status.hasTrouble, isTrue);
|
|
|
|
failWith = null;
|
|
sync.syncNow();
|
|
async.flushMicrotasks();
|
|
expect(sync.status.pending, 0);
|
|
sync.dispose();
|
|
});
|
|
});
|
|
|
|
test('saved while a sync runs, wait for the next', () {
|
|
fakeAsync((async) {
|
|
final running = hold = Completer<void>();
|
|
final sync = service()..start();
|
|
async.flushMicrotasks();
|
|
|
|
sync.requestSync();
|
|
running.complete();
|
|
async.flushMicrotasks();
|
|
expect(sync.status.pending, 1);
|
|
|
|
hold = null;
|
|
async.elapse(const Duration(seconds: 2));
|
|
expect(sync.status.pending, 0);
|
|
sync.dispose();
|
|
});
|
|
});
|
|
|
|
test('are kept while the app is closed, and taken by the first sync', () {
|
|
fakeAsync((async) {
|
|
failWith = Exception('No connection');
|
|
final kept = MemoryPendingChangesStore(3);
|
|
final sync = service(pendingChanges: kept)..start();
|
|
async.flushMicrotasks();
|
|
expect(sync.status.pending, 3);
|
|
|
|
sync.requestSync();
|
|
async.flushMicrotasks();
|
|
expect(kept.kept, 4);
|
|
|
|
failWith = null;
|
|
sync.syncNow();
|
|
async.flushMicrotasks();
|
|
expect(sync.status.pending, 0);
|
|
expect(kept.kept, 0);
|
|
sync.dispose();
|
|
});
|
|
});
|
|
});
|
|
|
|
test('says whether a sync ever went through on this device: as kept from '
|
|
'before, and from the first that goes through, kept for next '
|
|
'time', () {
|
|
fakeAsync((async) {
|
|
expect(service(syncedBefore: true).status.syncedHere, isTrue);
|
|
|
|
failWith = Exception('No connection');
|
|
final kept = MemoryPendingChangesStore(0, false);
|
|
final sync = service(pendingChanges: kept)..start();
|
|
async.flushMicrotasks();
|
|
expect(sync.status.syncedHere, isFalse);
|
|
expect(sync.status.hasTrouble, isTrue);
|
|
|
|
failWith = null;
|
|
sync.syncNow();
|
|
async.flushMicrotasks();
|
|
expect(sync.status.syncedHere, isTrue);
|
|
expect(kept.synced, isTrue);
|
|
|
|
sync.requestSync();
|
|
async.flushMicrotasks();
|
|
expect(sync.status.syncedHere, isTrue);
|
|
sync.dispose();
|
|
});
|
|
});
|
|
|
|
group('on mobile data (ADR 0042)', () {
|
|
late FakeNetworkWatch network;
|
|
late MobileDataGate gate;
|
|
|
|
setUp(() {
|
|
network = FakeNetworkWatch()..state = FakeNetworkWatch.mobile;
|
|
gate = MobileDataGate(network: network, offered: true);
|
|
});
|
|
|
|
tearDown(() => gate.dispose());
|
|
|
|
test('nothing syncs, the changes waiting counted, and it asks', () {
|
|
fakeAsync((async) {
|
|
final sync = service(mobileData: gate)..start();
|
|
async.flushMicrotasks();
|
|
sync.requestSync();
|
|
async.elapse(const Duration(minutes: 10));
|
|
|
|
expect(syncs, 0);
|
|
expect(sync.status.heldBack, isTrue);
|
|
expect(sync.status.asking, isTrue);
|
|
expect(sync.status.pending, 1);
|
|
expect(sync.status.mobileData, MobileData.ask);
|
|
expect(sync.filesAllowed, isFalse);
|
|
sync.dispose();
|
|
});
|
|
});
|
|
|
|
test('Sync now syncs at once, and on every trigger after, files and '
|
|
'all', () {
|
|
fakeAsync((async) {
|
|
final sync = service(mobileData: gate)..start();
|
|
async.flushMicrotasks();
|
|
|
|
sync.answer(SyncAllowance.everything);
|
|
async.flushMicrotasks();
|
|
expect(syncs, 1);
|
|
expect(sync.status.heldBack, isFalse);
|
|
expect(sync.status.asking, isFalse);
|
|
expect(sync.filesAllowed, isTrue);
|
|
|
|
async.elapse(const Duration(minutes: 5));
|
|
expect(syncs, 2);
|
|
sync.dispose();
|
|
});
|
|
});
|
|
|
|
test('Without files syncs the rows, the files held back', () {
|
|
fakeAsync((async) {
|
|
final sync = service(mobileData: gate)..start();
|
|
async.flushMicrotasks();
|
|
|
|
sync.answer(SyncAllowance.rows);
|
|
async.flushMicrotasks();
|
|
expect(syncs, 1);
|
|
expect(sync.filesAllowed, isFalse);
|
|
expect(sync.status.filesHeld, isTrue);
|
|
sync.dispose();
|
|
});
|
|
});
|
|
|
|
test('Not now stops asking until a new change waits; nothing syncs', () {
|
|
fakeAsync((async) {
|
|
final sync = service(mobileData: gate)..start();
|
|
async.flushMicrotasks();
|
|
sync
|
|
..requestSync()
|
|
..notNow();
|
|
expect(sync.status.asking, isFalse);
|
|
expect(sync.status.heldBack, isTrue);
|
|
|
|
sync.requestSync();
|
|
async.elapse(const Duration(seconds: 3));
|
|
expect(sync.status.asking, isTrue);
|
|
expect(syncs, 0);
|
|
sync.dispose();
|
|
});
|
|
});
|
|
|
|
test('asked to sync now (a list pulled down, Settings) is Sync now', () {
|
|
fakeAsync((async) {
|
|
final sync = service(mobileData: gate)..start();
|
|
async.flushMicrotasks();
|
|
|
|
sync.syncNow();
|
|
async.flushMicrotasks();
|
|
expect(syncs, 1);
|
|
expect(gate.allowance, SyncAllowance.everything);
|
|
sync.dispose();
|
|
});
|
|
});
|
|
|
|
test('Wi-Fi syncs at once what waited; back on mobile data, it asks '
|
|
'again', () {
|
|
fakeAsync((async) {
|
|
final sync = service(mobileData: gate)..start();
|
|
async.flushMicrotasks();
|
|
sync
|
|
..requestSync()
|
|
..notNow();
|
|
async.elapse(const Duration(seconds: 3));
|
|
expect(syncs, 0);
|
|
|
|
network.moveTo(FakeNetworkWatch.wifi);
|
|
async.flushMicrotasks();
|
|
expect(syncs, 1);
|
|
expect(sync.status.heldBack, isFalse);
|
|
expect(sync.status.pending, 0);
|
|
|
|
network.moveTo(FakeNetworkWatch.mobile);
|
|
async.elapse(const Duration(minutes: 5));
|
|
expect(syncs, 1);
|
|
expect(sync.status.asking, isTrue);
|
|
sync.dispose();
|
|
});
|
|
});
|
|
|
|
test('nothing syncs before the network is known', () {
|
|
fakeAsync((async) {
|
|
final sync = service(mobileData: gate)..start();
|
|
expect(syncs, 0);
|
|
async.flushMicrotasks();
|
|
expect(syncs, 0);
|
|
sync.dispose();
|
|
});
|
|
});
|
|
|
|
test('the setting is chosen through it, and shows in its status', () {
|
|
fakeAsync((async) {
|
|
final sync = service(mobileData: gate)..start();
|
|
async.flushMicrotasks();
|
|
|
|
sync.chooseMobileData(MobileData.withoutFiles);
|
|
async.flushMicrotasks();
|
|
expect(sync.status.mobileData, MobileData.withoutFiles);
|
|
expect(syncs, 1, reason: 'the rows may go now');
|
|
sync.dispose();
|
|
});
|
|
});
|
|
|
|
test("a device not offered the choice shows none, and doesn't hold "
|
|
'back', () {
|
|
fakeAsync((async) {
|
|
final desktop = MobileDataGate();
|
|
final sync = service(mobileData: desktop)..start();
|
|
async.flushMicrotasks();
|
|
expect(syncs, 1);
|
|
expect(sync.status.mobileData, isNull);
|
|
sync.dispose();
|
|
desktop.dispose();
|
|
});
|
|
});
|
|
});
|
|
}
|