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

View file

@ -8,9 +8,9 @@ Halkyon's apps and the packages they share, in one repository and one pub worksp
- **`apps/<app>/`**: each app's packages, as `serverpod create` lays them out (`halkyon_<app>_server`, `_client`, `_flutter`), plus what is the app's alone (Learn's curator, the scripts writing Learn's assets in `apps/learn/tool/`, run from `apps/learn`; the apps' icons are the root's `tool/app_icons.py`, ADR 0031). Learn is the app that runs; Train, Todo and Meditate run too (ADR 0030, ADR 0033, ADR 0038), Todo and Meditate not released yet.
- **`packages/`**: what the apps share, each with its README and licence, which the apps take by path (`path: ../../../packages/<package>`), so a change to a shared package and to the apps using it is one commit:
- `halkyon_ui`: Halkyon's look, the theme around cobalt and copper, Noto Sans bundled, the window-size classes and the widgets every screen is built of (the navigation bar flush with the foot with its fade, and the rail, `FoldingTabs`' tab row folding into the title), with texts of its own (English, German).
- `halkyon_ui`: Halkyon's look, the theme around cobalt and copper, Noto Sans bundled, the window-size classes and the widgets every screen is built of (the navigation bar flush with the foot with its fade, and the rail, a banner above every tab, `NavigationFrame.banner`, `FoldingTabs`' tab row folding into the title), with texts of its own (English, German).
- `halkyon_base_server`, `halkyon_base_client`: the Serverpod module every Halkyon server includes (ADR 0014): signing in (a password with TOTP and the resets of ADR 0017, SimpleLogin on `halkyon_oidc`, email codes proving an address; no anonymous accounts since ADR 0020, D25, nor GitHub since D36), an account's ways in and its deletion with a hook for each app's own data, the pruning, the instance's basics, people's handles and public profiles, who may sign up (`SIGN_UP`: open, approval, closed) with the accounts waiting for approval and the instance's operators who approve them (Halkyon Learn's ADR 0021, step 1), the operator's assistants forwarded on halkyon_llm with their grants, each person's budget, the efforts they allow and counted figures, and the instance's privacy page made from its settings (step 2), and the instance's register (ADR 0016): one account per person across an operator's services, held by one service (Learn's server on learn.halkyon.eu) and asked by the others, its members (`ACCOUNT_REGISTER_URL`), all sharing a secret (`INSTANCE_SECRET`); its README says how.
- `halkyon_app`: every app's shell on the module's client (ADR 0014): the instances a device keeps accounts on and the first start, signing in and switching person, the gates building an app's dependencies per instance and per person, the sync service and where it stands, the account section of an app's settings (`AccountTiles`), every app's Profile (`HalkyonProfile`, decided 2026-11-02: the person's own tab, the same in every app, with their account, the ways they sign in and their public profile, one for all apps, then the app's own tabs) and people's public profiles (`PublicProfileRepository`, `PublicProfileSection`, `showProfileOf`), the wait screen of an account waiting for approval and the operator's card, badge and screen (ADR 0021), the assistants of an app's settings (`AssistantsCard`: granted, by provider, agreed to, what is left of the person's budget, used, and the person's own keys) and the operator's grants from that screen (`AssistantGrantsScreen`: several assistants to several people at once, each with one budget, D57 to D63), with the repositories asking them through the module, at an effort the operator allows, or a person's own key directly (step 2) and what an app's warning before a private studyset goes names (`providerTermsOf`, `OwnAssistant.onThisMachine`, step 3), About with the instance's source link (`showAppAbout`), the device's own speech recognition (`SpeechInput`, `DeviceSpeechInput`: on Android the voice input app handling `RECOGNIZE_SPEECH` where one is installed, `VoiceInputApp` on each app's `VoiceInput.kt`, `app.halkyon/voice_input`, FUTO Voice Input for one, which records itself and returns text, else `speech_to_text`; `FakeSpeechInput` in `testing.dart`; none on Linux; Meditate's ADR 0038, D18) and dictation into a text at its cursor, listening again after each pause (`Dictation`, Meditate's ADR 0038, D15; an app that listens declares the microphone and its usage texts itself), what's new after an update (`WhatsNew`, `WhatsNewOnUpdate`, `WhatsNewTile`, from the app's `assets/whats_new/`, Train's ADR 0030, D61), the device's appearance and language, chosen in Settings and applied before anyone signs in (`Look`, `FileLookStore`, `LookCubit`, `DeviceLookCard`; Learn keeps them among its own device settings and shows `LookSettingsCard`; ADR 0014's amendment of 4 Nov 2026), the language every app picks (`resolveAppLocale`) and countries' and subdivisions' names (Unicode CLDR's, bundled), with texts that name the app.
- `halkyon_app`: every app's shell on the module's client (ADR 0014): the instances a device keeps accounts on and the first start, signing in and switching person, the gates building an app's dependencies per instance and per person, the sync service and where it stands, held back on a metered network as the device's `MobileDataGate` allows (ADR 0042: Android's flag through halkyon_app's own Android plugin, `HalkyonAppPlugin.java`, in Java so that it applies no Kotlin Gradle Plugin, `FakeNetworkWatch` in `testing.dart`; `mobileDataBannerOf` above every tab in each app's frame; the setting in `SyncStatusCard`; an app's files ask `SyncService.filesAllowed`), the account section of an app's settings (`AccountTiles`), every app's Profile (`HalkyonProfile`, decided 2026-11-02: the person's own tab, the same in every app, with their account, the ways they sign in and their public profile, one for all apps, then the app's own tabs) and people's public profiles (`PublicProfileRepository`, `PublicProfileSection`, `showProfileOf`), the wait screen of an account waiting for approval and the operator's card, badge and screen (ADR 0021), the assistants of an app's settings (`AssistantsCard`: granted, by provider, agreed to, what is left of the person's budget, used, and the person's own keys) and the operator's grants from that screen (`AssistantGrantsScreen`: several assistants to several people at once, each with one budget, D57 to D63), with the repositories asking them through the module, at an effort the operator allows, or a person's own key directly (step 2) and what an app's warning before a private studyset goes names (`providerTermsOf`, `OwnAssistant.onThisMachine`, step 3), About with the instance's source link (`showAppAbout`), the device's own speech recognition (`SpeechInput`, `DeviceSpeechInput`: on Android the voice input app handling `RECOGNIZE_SPEECH` where one is installed, `VoiceInputApp` on each app's `VoiceInput.kt`, `app.halkyon/voice_input`, FUTO Voice Input for one, which records itself and returns text, else `speech_to_text`; `FakeSpeechInput` in `testing.dart`; none on Linux; Meditate's ADR 0038, D18) and dictation into a text at its cursor, listening again after each pause (`Dictation`, Meditate's ADR 0038, D15; an app that listens declares the microphone and its usage texts itself), what's new after an update (`WhatsNew`, `WhatsNewOnUpdate`, `WhatsNewTile`, from the app's `assets/whats_new/`, Train's ADR 0030, D61), the device's appearance and language, chosen in Settings and applied before anyone signs in (`Look`, `FileLookStore`, `LookCubit`, `DeviceLookCard`; Learn keeps them among its own device settings and shows `LookSettingsCard`; ADR 0014's amendment of 4 Nov 2026), the language every app picks (`resolveAppLocale`) and countries' and subdivisions' names (Unicode CLDR's, bundled), with texts that name the app.
- `halkyon_editor` (ADR 0033, D37, 6 Nov 2026): rich text, the general parts of Learn's editor on our fork of `appflowy_editor`: `RichContent` (the editor's document JSON, which Learn and Todo store), `RichContentEditor`, `RichContentView`, the format bar, links, the clipboard keeping formats, superscript and subscript, and `EditorBlocks`, through which an app adds blocks, marks within lines and their buttons (`basicBlocks` without any), with texts of its own (English, German). What is Learn's alone stays in Learn on top of it.
- `halkyon_core` (low-level primitives in pure Dart: ids, a logger, `SerialQueue`, `CoalescingRunner`) and `halkyon_interop` (contracts between apps, pure Dart).
- `halkyon_keys` (pure Dart on libsodium, ADR 0017): an account's keys on the device, the password's Argon2id and SRP's x, the key pairs and their wrapping, the recovery phrase, data keys, content sealed and signed, row IDs keyed by the account (`keyedRowId`). SRP-6a's two halves are the module's (`halkyon_base_server`'s `base.dart`, `halkyon_base_client`'s `srp.dart`), as are TOTP and the recovery codes. `sodium`'s build hook compiles libsodium, so every machine that builds or tests needs a C compiler and `make`.

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

View file

@ -1,7 +1,7 @@
# ADR 0042: Sync held back on mobile data
- **Status:** decided 10 Nov 2026 by the developer: D1 is their own request, D2 to D5
the agent's recommendations, which they chose. Not built yet.
the agent's recommendations, which they chose. Built 10 Nov 2026 (below).
- **Builds on:** ADR 0014 (halkyon_app, every app's shell, with its `SyncService`).
## Context
@ -53,3 +53,35 @@ card (*3 files waiting for Wi-Fi*).
and sending nothing.
- Learn's offline reads (b578fcb9) must not wait on a sync held back: the tests of
`ContentKeys` and the gateways already run without one.
## As built (10 Nov 2026)
The developer chose two more things while it was built: the native code as halkyon_app's
own Android plugin, not a Kotlin file in each app, and the banner in the frame, above
every tab.
- **halkyon_app's plugin** (`android/`, `HalkyonAppPlugin.java`): the first native code of
a shared package, registered in every app by Flutter. Java, so it applies no Kotlin
Gradle Plugin. It answers the default network on `app.halkyon/network` and sends each
change on `app.halkyon/network/changes`: its handle as `id`, and `metered` where
`NET_CAPABILITY_NOT_METERED` is missing (unless temporarily not metered, Android 11 on).
It declares `ACCESS_NETWORK_STATE`. Dart: `AndroidNetworkWatch`, `NeverMetered` on other
platforms, `deviceNetworkWatch()`.
- **`MobileDataGate`** (the device's, one for every person's sync): the setting, kept in
`<app support>/mobile_data.json`; the network; the answer, bound to the network's `id`
and dropped when the device moves to another network or off a metered one; `allowance`
(`nothing`, `rows`, `everything`) and `filesAllowed`. Nothing syncs until it knows the
network, or for 3 s at most.
- **`SyncService(mobileData:)`**: a trigger while nothing is allowed syncs nothing; the
status says `heldBack`, `asking` (until *Not now*, and again once another change
waits), `filesHeld` and the setting (`mobileData`, null on a desktop). `syncNow` while
held back answers *Sync now*. The gate allowing more syncs at once.
- **The banner**: `mobileDataBannerOf(context)` in each app's `NavigationFrame.banner`
(halkyon_ui: above the tabs, their screens without the top inset, which the banner keeps
clear of itself). **Settings**: the Sync card's *On mobile data* field and its lines
*Held back on mobile data* and *Files wait for Wi-Fi*.
- **Files**: Learn's `AssetRepository(filesAllowed:)` uploads nothing and downloads
nothing while files are held, as without a connection; what waits goes after the next
sync that may carry it. Todo's attachments (ADR 0043) will do the same.
- **Not built**: the count of files waiting in the Sync card; iOS (`NWPath.isExpensive`).

View file

@ -6,6 +6,7 @@ analyzer:
- build/**
# Generated by gen-l10n (see l10n.yaml).
- lib/l10n/halkyon_app_localizations*.dart
- android/**
formatter:
trailing_commas: preserve

View file

@ -0,0 +1,22 @@
// halkyon_app's Android half (Halkyon's ADR 0042): Java alone, built by each
// app's Gradle with the app's Android Gradle Plugin.
group = "app.halkyon.halkyon_app"
version = "1.0"
plugins {
id("com.android.library")
}
android {
namespace = "app.halkyon.halkyon_app"
compileSdk = flutter.compileSdkVersion
defaultConfig {
minSdk = 24
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}

View file

@ -0,0 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Which network the device is on, and whether it is metered (ADR 0042). -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
</manifest>

View file

@ -0,0 +1,119 @@
package app.halkyon.halkyon_app;
import android.content.Context;
import android.net.ConnectivityManager;
import android.net.Network;
import android.net.NetworkCapabilities;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import androidx.annotation.NonNull;
import io.flutter.embedding.engine.plugins.FlutterPlugin;
import io.flutter.plugin.common.EventChannel;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
import java.util.HashMap;
import java.util.Map;
/**
* What every Halkyon app needs from Android (Halkyon's ADR 0042): the default
* network and whether it is metered, as `AndroidNetworkWatch` in Dart reads
* it. Metered is what lacks NET_CAPABILITY_NOT_METERED (cellular, a phone's
* hotspot, Wi-Fi marked metered), unless it is temporarily not metered (an
* unmetered 5G plan, Android 11 on).
*/
public final class HalkyonAppPlugin
implements FlutterPlugin, MethodChannel.MethodCallHandler, EventChannel.StreamHandler {
private MethodChannel methods;
private EventChannel events;
private ConnectivityManager connectivity;
private ConnectivityManager.NetworkCallback callback;
private final Handler main = new Handler(Looper.getMainLooper());
@Override
public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) {
connectivity =
(ConnectivityManager)
binding.getApplicationContext().getSystemService(Context.CONNECTIVITY_SERVICE);
methods = new MethodChannel(binding.getBinaryMessenger(), "app.halkyon/network");
methods.setMethodCallHandler(this);
events = new EventChannel(binding.getBinaryMessenger(), "app.halkyon/network/changes");
events.setStreamHandler(this);
}
@Override
public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) {
methods.setMethodCallHandler(null);
events.setStreamHandler(null);
onCancel(null);
}
@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
if ("current".equals(call.method)) {
Network network = connectivity.getActiveNetwork();
result.success(stateOf(network, network == null ? null : capabilitiesOf(network)));
} else {
result.notImplemented();
}
}
@Override
public void onListen(Object arguments, EventChannel.EventSink sink) {
onCancel(null);
callback =
new ConnectivityManager.NetworkCallback() {
@Override
public void onCapabilitiesChanged(
@NonNull Network network, @NonNull NetworkCapabilities capabilities) {
send(sink, stateOf(network, capabilities));
}
@Override
public void onLost(@NonNull Network network) {
send(sink, stateOf(null, null));
}
};
connectivity.registerDefaultNetworkCallback(callback);
}
@Override
public void onCancel(Object arguments) {
if (callback != null) {
try {
connectivity.unregisterNetworkCallback(callback);
} catch (IllegalArgumentException alreadyGone) {
// Not registered any more: nothing to undo.
}
callback = null;
}
}
/** The callbacks come on the system's thread; the channel takes the main one. */
private void send(EventChannel.EventSink sink, Map<String, Object> state) {
main.post(() -> sink.success(state));
}
private NetworkCapabilities capabilitiesOf(Network network) {
try {
return connectivity.getNetworkCapabilities(network);
} catch (SecurityException refused) {
return null;
}
}
private static Map<String, Object> stateOf(Network network, NetworkCapabilities capabilities) {
Map<String, Object> state = new HashMap<>();
state.put("id", network == null ? "none" : String.valueOf(network.getNetworkHandle()));
state.put("metered", capabilities != null && isMetered(capabilities));
return state;
}
private static boolean isMetered(NetworkCapabilities capabilities) {
if (capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)) {
return false;
}
return Build.VERSION.SDK_INT < Build.VERSION_CODES.R
|| !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED);
}
}

View file

@ -190,10 +190,13 @@ export 'src/speech/speech_input.dart';
export 'src/speech/voice_input_app.dart';
export 'src/sync/app_compatibility.dart';
export 'src/sync/bloc/sync_status_bloc.dart';
export 'src/sync/network/mobile_data_gate.dart';
export 'src/sync/network/network_watch.dart';
export 'src/sync/pending_changes_store.dart';
export 'src/sync/sync_service.dart';
export 'src/sync/sync_status.dart';
export 'src/sync/widget/app_mismatch_banner.dart';
export 'src/sync/widget/mobile_data_banner.dart';
export 'src/sync/widget/sync_and_wait.dart';
export 'src/sync/widget/sync_status_card.dart';
export 'src/sync/widget/sync_trouble_badge.dart';

View file

@ -522,5 +522,14 @@
"lookAppearanceLight": "Hell",
"lookAppearanceDark": "Dunkel",
"lookLanguage": "Sprache",
"lookLanguageDevice": "Gerätesprache"
"lookLanguageDevice": "Gerätesprache",
"syncStateHeldBack": "Mit mobilen Daten angehalten",
"syncFilesHeld": "Dateien warten auf WLAN",
"mobileDataTitle": "Mit mobilen Daten",
"mobileDataAsk": "Erst fragen",
"mobileDataWithoutFiles": "Ohne Dateien synchronisieren",
"mobileDataEverything": "Alles synchronisieren",
"mobileDataBanner": "Mobile Daten · {count, plural, =0{Synchronisierung pausiert} =1{1 Änderung wartet} other{{count} Änderungen warten}}",
"mobileDataBannerWithoutFiles": "Ohne Dateien",
"mobileDataBannerNotNow": "Nicht jetzt"
}

View file

@ -1591,5 +1591,23 @@
"lookAppearanceLight": "Light",
"lookAppearanceDark": "Dark",
"lookLanguage": "Language",
"lookLanguageDevice": "Device language"
"lookLanguageDevice": "Device language",
"syncStateHeldBack": "Held back on mobile data",
"@syncStateHeldBack": {"description": "The Sync card's state while sync waits for the person's answer or Wi-Fi (Halkyon's ADR 0042)."},
"syncFilesHeld": "Files wait for Wi-Fi",
"@syncFilesHeld": {"description": "The Sync card's line while rows sync on mobile data and files wait (ADR 0042)."},
"mobileDataTitle": "On mobile data",
"@mobileDataTitle": {"description": "The Sync card's setting: what the device syncs on a metered network unasked (ADR 0042, D5)."},
"mobileDataAsk": "Ask first",
"@mobileDataAsk": {"description": "A choice of mobileDataTitle: sync waits until the person answers the banner."},
"mobileDataWithoutFiles": "Sync without files",
"@mobileDataWithoutFiles": {"description": "A choice of mobileDataTitle: rows sync, files wait for Wi-Fi."},
"mobileDataEverything": "Sync everything",
"@mobileDataEverything": {"description": "A choice of mobileDataTitle."},
"mobileDataBanner": "On mobile data · {count, plural, =0{sync paused} =1{1 change waiting} other{{count} changes waiting}}",
"@mobileDataBanner": {"description": "The banner above every tab while sync is held back on mobile data (ADR 0042, D3); count is the changes made here not synced yet.", "placeholders": {"count": {"type": "int"}}},
"mobileDataBannerWithoutFiles": "Without files",
"@mobileDataBannerWithoutFiles": {"description": "The banner's action syncing the rows alone, files waiting for Wi-Fi."},
"mobileDataBannerNotNow": "Not now",
"@mobileDataBannerNotNow": {"description": "The banner's action hiding it until another change waits."}
}

Some files were not shown because too many files have changed in this diff Show more