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

File diff suppressed because one or more lines are too long

View file

@ -37,6 +37,10 @@ class AssetRepository(
final AssetFileCache _files,
final AssetSealing _sealing, {
Stream<void> uploadTriggers = const Stream.empty(),
/// Whether files may go up and come down now: not while sync holds them
/// back on mobile data (Halkyon's ADR 0042); always where not said.
final bool Function() _filesAllowed = _always,
}) implements TreeFiles {
this {
_triggers = uploadTriggers.listen((_) {
@ -321,6 +325,10 @@ class AssetRepository(
}
Future<Uint8List?> _download(String id, String hash) async {
if (!_filesAllowed()) {
// As without a connection: what this device lacks waits.
return null;
}
try {
final bytes = await _server.download(id);
if (bytes == null || sha256.convert(bytes).toString() != hash) {
@ -339,7 +347,7 @@ class AssetRepository(
Future<void> uploadPending() => _uploads.run();
Future<void> _uploadAll() async {
if (_stopped) {
if (_stopped || !_filesAllowed()) {
return;
}
for (final hash in await _files.pendingUploads()) {
@ -425,3 +433,5 @@ class AssetRepository(
await _uploads.idle;
}
}
bool _always() => true;

View file

@ -356,6 +356,7 @@ class Dependencies({
await folders.openOnly(person.authUserId),
accountKeys: () => accountKeys.read(person.authUserId),
pdfEngine: pdfEngine,
mobileData: device.mobileData,
onClosed: closed.complete,
);
} on Object {
@ -408,6 +409,10 @@ class DeviceParts({
/// The device's way to the person's Jellyfin server.
required final JellyfinRepository jellyfin,
/// What sync may do on the network the device is on (Halkyon's ADR
/// 0042): every person's on this device.
final MobileDataGate? mobileData,
}) {
/// Makes them, [builtIn] being the instance the build names.
static Future<DeviceParts> create({required Instance builtIn}) async {
@ -426,6 +431,9 @@ class DeviceParts({
deviceSettingsStore: FileDeviceSettingsStore(
File(p.join(support, 'device_settings.json')),
),
mobileData: await MobileDataGate.forDevice(
File(p.join(support, 'mobile_data.json')),
),
jellyfin: JellyfinRepository(
(device) => HttpJellyfinGateway(http.Client(), device: device),
SecureJellyfinStore(),
@ -452,6 +460,7 @@ Future<PersonDependencies> _openPersonData(
Directory folder, {
required Future<UnlockedAccountKeys?> Function() accountKeys,
required PdfEngine pdfEngine,
required MobileDataGate? mobileData,
required void Function() onClosed,
}) async {
final db = await client.createSyncSession(
@ -473,6 +482,7 @@ Future<PersonDependencies> _openPersonData(
),
pendingChanges: kept,
syncedBefore: (await kept.read()).synced,
mobileData: mobileData,
)..start();
// The person's keys, with which private studysets, their tags and their
// settings are sealed and opened (ADR 0019).
@ -535,6 +545,7 @@ Future<PersonDependencies> _openPersonData(
DirectoryAssetFileCache(Directory(p.join(folder.path, 'files'))),
privacy,
uploadTriggers: sync.synced,
filesAllowed: () => sync.filesAllowed,
);
// A tree made private or not, or moved, takes its files along (ADR 0019);
// what a run left, or a device that hadn't heard wrote, is sealed as the

View file

@ -48,6 +48,8 @@ class const AppShell({
// After an update, what's new shows once.
return WhatsNewOnUpdate(
child: NavigationFrame(
// Sync held back on mobile data asks above every tab (ADR 0042).
banner: mobileDataBannerOf(context),
appName: l10n.appTitle,
// The icon has a white background, which the frame's rounded tile
// keeps reading on light and dark rails alike.

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

View file

@ -259,6 +259,7 @@ class MeditateDependencies({
screen: device.screen,
speech: device.speech,
guide: device.guide,
mobileData: device.mobileData,
onClosed: closed.complete,
);
} on Object {
@ -298,6 +299,7 @@ Future<MeditatePerson> _openPersonData(
required ScreenAwake screen,
required SpeechInput speech,
required GuidePlayer guide,
required MobileDataGate? mobileData,
required void Function() onClosed,
}) async {
final db = await client.createSyncSession(
@ -319,6 +321,7 @@ Future<MeditatePerson> _openPersonData(
),
pendingChanges: kept,
syncedBefore: (await kept.read()).synced,
mobileData: mobileData,
)..start();
// The person's key, with which everything of theirs is sealed (D5); read
// again whenever the rows are, as a sync may have brought it.
@ -398,6 +401,10 @@ class MeditateDevice({
/// The player of the library's recordings, one for the whole app (D16).
final GuidePlayer guide = const NoGuidePlayer(),
/// What sync may do on the network the device is on (Halkyon's ADR
/// 0042): every person's on this device.
final MobileDataGate? mobileData,
}) {
/// How long the recogniser may wait through a pause while a dream is
/// told, and how long one listen may last: as long as the platform allows
@ -412,6 +419,9 @@ class MeditateDevice({
final support = (await getApplicationSupportDirectory()).path;
return MeditateDevice(
support: support,
mobileData: await MobileDataGate.forDevice(
File(p.join(support, 'mobile_data.json')),
),
storage: InstanceStorage(support, builtIn: builtIn),
appBuild: int.tryParse(package.buildNumber),
appVersion: package.version,

View file

@ -23,6 +23,8 @@ class const MeditateShell({
final l10n = AppLocalizations.of(context);
return WhatsNewOnUpdate(
child: NavigationFrame(
// Sync held back on mobile data asks above every tab (ADR 0042).
banner: mobileDataBannerOf(context),
// Open into the sidebar or not, as the device keeps it (decided
// 10 Nov 2026).
sidebarOpen: context.select<LookCubit, bool>(

View file

@ -374,6 +374,7 @@ class TodoDependencies({
locationAccess: device.locationAccess,
paneWidths: device.paneWidths,
completionFeedback: device.completionFeedback,
mobileData: device.mobileData,
onClosed: closed.complete,
);
} on Object {
@ -416,6 +417,7 @@ Future<TodoPerson> _openPersonData(
required LocationReminderAccess locationAccess,
required PaneWidths paneWidths,
required CompletionFeedback completionFeedback,
required MobileDataGate? mobileData,
required void Function() onClosed,
}) async {
final db = await client.createSyncSession(
@ -437,6 +439,7 @@ Future<TodoPerson> _openPersonData(
),
pendingChanges: kept,
syncedBefore: (await kept.read()).synced,
mobileData: mobileData,
)..start();
// The person's key, with which everything of theirs is sealed (D5); read
// again whenever the projects are, as a sync may have brought it.
@ -537,6 +540,10 @@ class TodoDevice({
/// What a task done feels like, the sound turned off or not.
required final CompletionFeedback completionFeedback,
/// What sync may do on the network the device is on (Halkyon's ADR
/// 0042): every person's on this device.
final MobileDataGate? mobileData,
}) {
/// Whether the device may show reminders, asked once an app start,
/// whoever is signed in (D31).
@ -553,6 +560,9 @@ class TodoDevice({
final support = (await getApplicationSupportDirectory()).path;
return TodoDevice(
support: support,
mobileData: await MobileDataGate.forDevice(
File(p.join(support, 'mobile_data.json')),
),
storage: InstanceStorage(support, builtIn: builtIn),
appBuild: int.tryParse(package.buildNumber),
appVersion: package.version,

View file

@ -52,6 +52,8 @@ class _TodoShellState() extends State<TodoShell> {
listenable: Listenable.merge([_selection, _hold]),
builder: (context, child) => WhatsNewOnUpdate(
child: NavigationFrame(
// Sync held back on mobile data asks above every tab (ADR 0042).
banner: mobileDataBannerOf(context),
// Open into the sidebar or not, as the device keeps it (decided
// 10 Nov 2026).
sidebarOpen: context.select<LookCubit, bool>(

View file

@ -373,6 +373,7 @@ class TrainDependencies({
await folders.openOnly(person.authUserId),
appVersion: device.appVersion,
accountKeys: () => accountKeys.read(person.authUserId),
mobileData: device.mobileData,
onClosed: closed.complete,
);
} on Object {
@ -410,6 +411,7 @@ Future<TrainPerson> _openPersonData(
Directory folder, {
required String appVersion,
required Future<UnlockedAccountKeys?> Function() accountKeys,
required MobileDataGate? mobileData,
required void Function() onClosed,
}) async {
final db = await client.createSyncSession(
@ -431,6 +433,7 @@ Future<TrainPerson> _openPersonData(
),
pendingChanges: kept,
syncedBefore: (await kept.read()).synced,
mobileData: mobileData,
)..start();
// The person's key, with which everything of theirs is sealed (D15); read
// again whenever the workouts are, as a sync may have brought it.
@ -595,6 +598,10 @@ class TrainDevice({
/// This build's version (before the `+`), which Jellyfin lists Train's
/// sign-ins by (D47).
final String appVersion = '',
/// What sync may do on the network the device is on (Halkyon's ADR
/// 0042): every person's on this device.
final MobileDataGate? mobileData,
}) {
/// Makes them, [builtIn] being the instance the build names.
static Future<TrainDevice> create({required Instance builtIn}) async {
@ -602,6 +609,9 @@ class TrainDevice({
final support = (await getApplicationSupportDirectory()).path;
return TrainDevice(
support: support,
mobileData: await MobileDataGate.forDevice(
File(p.join(support, 'mobile_data.json')),
),
storage: InstanceStorage(support, builtIn: builtIn),
appBuild: int.tryParse(package.buildNumber),
appVersion: package.version,

View file

@ -49,6 +49,8 @@ class const TrainShell({
(bloc) => showsRecordingBar(bloc.state),
);
final frame = NavigationFrame(
// Sync held back on mobile data asks above every tab (ADR 0042).
banner: mobileDataBannerOf(context),
// Open into the sidebar or not, as the device keeps it (decided
// 10 Nov 2026).
sidebarOpen: context.select<LookCubit, bool>(