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
5 changed files with 1511 additions and 62 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue