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,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);
}
}