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
3 changed files with 1511 additions and 62 deletions

View file

@ -0,0 +1,149 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:path/path.dart' as p;
void main() {
late FakeNetworkWatch network;
late MobileDataGate gate;
setUp(() {
network = FakeNetworkWatch();
gate = MobileDataGate(network: network, offered: true);
addTearDown(gate.dispose);
});
test('on Wi-Fi everything goes, whatever is set', () async {
await gate.ready;
for (final setting in MobileData.values) {
await gate.choose(setting);
expect(gate.metered, isFalse);
expect(gate.allowance, SyncAllowance.everything);
expect(gate.filesAllowed, isTrue);
}
});
test('on mobile data, asking holds back everything until answered', () async {
network.state = FakeNetworkWatch.mobile;
await gate.ready;
expect(gate.metered, isTrue);
expect(gate.allowance, SyncAllowance.nothing);
expect(gate.filesAllowed, isFalse);
gate.answer(SyncAllowance.rows);
expect(gate.allowance, SyncAllowance.rows, reason: 'without files');
expect(gate.filesAllowed, isFalse);
gate.answer(SyncAllowance.everything);
expect(gate.filesAllowed, isTrue);
});
test("set to Without files or Everything, mobile data doesn't ask", () async {
network.state = FakeNetworkWatch.mobile;
await gate.ready;
await gate.choose(MobileData.withoutFiles);
expect(gate.allowance, SyncAllowance.rows);
await gate.choose(MobileData.everything);
expect(gate.allowance, SyncAllowance.everything);
});
test('an answer holds until the connection changes: Wi-Fi come and gone, '
'or another metered network, asks again', () async {
network.state = FakeNetworkWatch.mobile;
await gate.ready;
var changes = 0;
gate
..addListener(() => changes++)
..answer(SyncAllowance.everything);
expect(changes, 1);
network.moveTo(FakeNetworkWatch.mobile);
expect(changes, 1, reason: 'the same network again changes nothing');
expect(gate.allowance, SyncAllowance.everything);
network
..moveTo(FakeNetworkWatch.wifi)
..moveTo(FakeNetworkWatch.mobile);
expect(gate.allowance, SyncAllowance.nothing);
gate.answer(SyncAllowance.everything);
network.moveTo((id: 'hotspot', metered: true));
expect(gate.allowance, SyncAllowance.nothing);
});
test('nothing is answered off a metered network', () async {
await gate.ready;
gate.answer(SyncAllowance.everything);
network.moveTo(FakeNetworkWatch.mobile);
expect(gate.allowance, SyncAllowance.nothing);
});
test('a desktop is never metered, and is offered no choice', () async {
final desktop = MobileDataGate();
addTearDown(desktop.dispose);
await desktop.ready;
expect(desktop.offered, isFalse);
expect(desktop.allowance, SyncAllowance.everything);
});
group('kept on the device', () {
late Directory folder;
setUp(() async {
folder = await Directory.systemTemp.createTemp('mobile_data');
addTearDown(() => folder.delete(recursive: true));
});
test('the setting stays when the app opens again; an answer goes with '
'the app', () async {
final file = File(p.join(folder.path, 'mobile_data.json'));
network.state = FakeNetworkWatch.mobile;
final first = await MobileDataGate.open(
file,
network: network,
offered: true,
);
addTearDown(first.dispose);
await first.ready;
await first.choose(MobileData.withoutFiles);
final again = await MobileDataGate.open(
file,
network: network,
offered: true,
);
addTearDown(again.dispose);
await again.ready;
expect(again.mobileData, MobileData.withoutFiles);
await again.choose(MobileData.ask);
again.answer(SyncAllowance.everything);
final third = await MobileDataGate.open(
file,
network: network,
offered: true,
);
addTearDown(third.dispose);
await third.ready;
expect(third.allowance, SyncAllowance.nothing);
});
test('a file that is no setting asks', () async {
final file = File(p.join(folder.path, 'mobile_data.json'));
for (final content in ['not json', '[1]', '{"mobileData": "never"}']) {
await file.writeAsString(content);
final gate = await MobileDataGate.open(
file,
network: network,
offered: true,
);
addTearDown(gate.dispose);
expect(gate.mobileData, MobileData.ask, reason: content);
}
});
});
}

View file

@ -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();
});
});
});
}

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
@ -168,4 +170,122 @@ void main() {
expect(find.textContaining('Update it to keep syncing'), findsNothing);
});
group('on mobile data (ADR 0042)', () {
late FakeNetworkWatch network;
late int syncs;
/// Shows the banner above the Sync card, on a phone ([offered]) on
/// mobile data, sync started; [changes] made here wait.
Future<(SyncService, MobileDataGate)> showPhone(
WidgetTester tester, {
bool offered = true,
int changes = 0,
}) async {
final gate = MobileDataGate(network: network, offered: offered);
addTearDown(gate.dispose);
final sync = SyncService(({required onMerge}) async {
syncs++;
}, mobileData: gate)..start();
for (var change = 0; change < changes; change++) {
sync.requestSync();
}
await tester.pumpWidget(
BlocProvider(
create: (context) =>
SyncStatusBloc(sync)..add(const SyncStatusEventStarted()),
child: testApp(
Scaffold(
body: Builder(
builder: (context) => Column(
children: [
?mobileDataBannerOf(context),
const Expanded(
child: SingleChildScrollView(child: SyncStatusCard()),
),
],
),
),
),
dependencies: TestAppDependencies(),
),
),
);
await tester.pump(const Duration(seconds: 3));
await tester.pumpAndSettle();
return (sync, gate);
}
setUp(() {
network = FakeNetworkWatch()..state = FakeNetworkWatch.mobile;
syncs = 0;
});
testWidgets('the banner counts the changes waiting; Not now puts it off '
'until another waits', (tester) async {
final (sync, _) = await showPhone(tester, changes: 2);
expect(find.text('On mobile data · 2 changes waiting'), findsOneWidget);
expect(find.text('Held back on mobile data'), findsOneWidget);
expect(syncs, 0);
await tester.tap(find.text('Not now'));
await tester.pumpAndSettle();
expect(find.byType(MobileDataBanner), findsNothing);
sync.requestSync();
await tester.pump(const Duration(seconds: 3));
await tester.pumpAndSettle();
expect(find.text('On mobile data · 3 changes waiting'), findsOneWidget);
expect(syncs, 0);
unawaited(sync.dispose());
});
testWidgets('Sync now syncs and the banner goes', (tester) async {
final (sync, _) = await showPhone(tester);
expect(find.text('On mobile data · sync paused'), findsOneWidget);
await tester.tap(find.text('Sync now').first);
await tester.pumpAndSettle();
expect(syncs, 1);
expect(find.byType(MobileDataBanner), findsNothing);
unawaited(sync.dispose());
});
testWidgets('Without files syncs, and the Sync card says the files wait', (
tester,
) async {
final (sync, _) = await showPhone(tester);
await tester.tap(find.text('Without files'));
await tester.pumpAndSettle();
expect(syncs, 1);
expect(find.byType(MobileDataBanner), findsNothing);
expect(find.text('Files wait for Wi-Fi'), findsOneWidget);
unawaited(sync.dispose());
});
testWidgets('the Sync card chooses what goes unasked; Sync everything '
"syncs and doesn't ask", (tester) async {
final (sync, gate) = await showPhone(tester);
expect(find.text('Ask first'), findsOneWidget);
await tester.tap(find.text('Ask first'));
await tester.pumpAndSettle();
await tester.tap(find.text('Sync everything').last);
await tester.pumpAndSettle();
expect(gate.mobileData, MobileData.everything);
expect(syncs, 1);
expect(find.byType(MobileDataBanner), findsNothing);
unawaited(sync.dispose());
});
testWidgets('a desktop shows no banner and no choice', (tester) async {
network.state = FakeNetworkWatch.wifi;
final (sync, _) = await showPhone(tester, offered: false);
expect(find.byType(MobileDataBanner), findsNothing);
expect(find.text('On mobile data'), findsNothing);
expect(syncs, 1);
unawaited(sync.dispose());
});
});
}