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:
parent
5d47efd7c5
commit
59fb9c4d6d
1 changed files with 1511 additions and 62 deletions
|
|
@ -19,6 +19,7 @@ void main() {
|
|||
SyncService service({
|
||||
PendingChangesStore? pendingChanges,
|
||||
bool syncedBefore = false,
|
||||
MobileDataGate? mobileData,
|
||||
}) => SyncService(
|
||||
({required onMerge}) async {
|
||||
syncs++;
|
||||
|
|
@ -30,6 +31,7 @@ void main() {
|
|||
},
|
||||
pendingChanges: pendingChanges,
|
||||
syncedBefore: syncedBefore,
|
||||
mobileData: mobileData,
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
|
|
@ -387,4 +389,158 @@ void main() {
|
|||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue