From d2471a6ce36e643f8d3f31800a2c842a616f501b Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 13 Jun 2026 20:39:33 +0530 Subject: [PATCH] Fix background sync: hold BLE connection alive + persistent flusher + honest upload count iOS only ever synced on app-open: pauseForBackground disconnected the band, so iOS dropped the Bluetooth assertion and suspended the app in ~34s, and the restore central never armed. Now we KEEP the live connection in the background (iOS resumes us per BLE notification under bluetooth-central) so the drain + upload continue. - Add one connection-independent flusher (~15s) that uploads queued records and retries anything a prior tick failed to send (uploader retains rows on any non-200). Removes the per-record flushing that was tripping the backend rate limit. No cooldown. - Show the honest uploaded count: read `received` from the ingest response (the client was reading a non-existent `processed` field, so it always showed 0/N). - BleRestoreManager is recovery-only: event-driven re-arm via setOwnsBand, and the 50-min cooldown timer is gone (replaced by an idle-after-sync flag). - Remove the 15-min WorkManager periodic task (iOS + Android) and all its plumbing; Android keep-alive is the Edge Tracking foreground service, iOS is the kept-alive connection with CB state-restoration as recovery only. - Ensure the flusher + Android foreground service start on every session-establish path (init/login/reconnect/resume), not just openSession. - Cleanup: drop the workmanager dependency, the BGTask/processing/fetch Info.plist entries, and RECEIVE_BOOT_COMPLETED. --- android/app/src/main/AndroidManifest.xml | 1 - ios/Runner/AppDelegate.swift | 14 +-- ios/Runner/BleRestoreManager.swift | 97 ++++++++++------- ios/Runner/Info.plist | 8 -- lib/ble/ios_ble_restore.dart | 14 ++- lib/main.dart | 4 - lib/state/app_state.dart | 133 +++++++++++++++++++---- lib/sync/background_sync.dart | 59 ++-------- lib/sync/uploader.dart | 7 +- pubspec.lock | 32 ------ pubspec.yaml | 4 - 11 files changed, 202 insertions(+), 171 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fd9b41603..a5ae36d6a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -9,7 +9,6 @@ android:maxSdkVersion="30" /> - diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index bce4f7936..a85e99798 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,7 +1,5 @@ import Flutter import UIKit -// workmanager 0.9 split the iOS plugin into the `workmanager_apple` module. -import workmanager_apple @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { @@ -9,15 +7,9 @@ import workmanager_apple _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - // Background sync (workmanager / BGTaskScheduler). - WorkmanagerPlugin.setPluginRegistrantCallback { registry in - GeneratedPluginRegistrant.register(with: registry) - } - // workmanager 0.9 API. Identifier must match BGTaskSchedulerPermittedIdentifiers. - WorkmanagerPlugin.registerPeriodicTask( - withIdentifier: "openstrap.periodicSync", - frequency: NSNumber(value: 15 * 60) - ) + // No OS periodic background task (no WorkManager 15-min / BGTask): continuous sync is + // the kept-alive live BLE connection + the persistent flusher in AppState, with + // CoreBluetooth state restoration below as the relaunch-recovery fallback. // CoreBluetooth state restoration — must be created here (early) so iOS can relaunch // us with willRestoreState when the band reappears. Wakes the app → headless sync. diff --git a/ios/Runner/BleRestoreManager.swift b/ios/Runner/BleRestoreManager.swift index 276b5b2fe..22d1fa26d 100644 --- a/ios/Runner/BleRestoreManager.swift +++ b/ios/Runner/BleRestoreManager.swift @@ -13,18 +13,19 @@ import Flutter /// relaunches us when the band shows up, then it cancels its own connection and tells /// Flutter to run the normal headless sync. /// -/// Pacing: a no-timeout pending connect to an in-range band fires immediately, so left -/// alone it would reconnect-drain in a loop. After each sync we enter a cooldown -/// (~50 min) before re-arming, which lands the cadence near "once an hour" while the -/// band is around, and still syncs promptly when the band returns after being away. -/// Arming only happens while backgrounded; in the foreground flutter_blue_plus owns the -/// band. +/// This is RECOVERY-ONLY: normal sync is the kept-alive live connection + the AppState +/// flusher. The restore central arms a no-timeout pending connect ONLY when Dart tells +/// it the connection dropped (`setOwnsBand(false)` / `arm`). No timers, no cooldown. +/// +/// Loop prevention is event-driven, not time-based: after a wake hands off to Dart and +/// Dart reports the drain done (`syncDone`), we go IDLE and do NOT re-arm. We re-arm only +/// on the next explicit request from Dart (a fresh disconnect). Arming only happens while +/// backgrounded; in the foreground flutter_blue_plus owns the band. class BleRestoreManager: NSObject { static let shared = BleRestoreManager() private static let restoreId = "openstrap.ble.restore" private static let bandUUIDKey = "openstrap.ble.band_uuid" - private let cooldownSeconds: TimeInterval = 50 * 60 private var central: CBCentralManager? private var bandUUID: UUID? @@ -33,9 +34,15 @@ class BleRestoreManager: NSObject { private var flutterReady = false private var wakeQueuedBeforeReady = false private var handedOff = false // true between wake → Dart's syncDone - private var coolingDown = false - private var cooldownWork: DispatchWorkItem? + /// Set after a wake's sync completes; suppresses re-arming until Dart explicitly + /// re-arms on the next disconnect. Replaces the old time-based cooldown — no loop, + /// no timer. + private var idleAfterSync = false private var bgTask: UIBackgroundTaskIdentifier = .invalid + /// True while the app holds the live flutter_blue_plus connection (foreground OR + /// backgrounded-but-connected). The restore central must not arm a competing connect + /// to the same peripheral while this is true. + private var appOwnsBand = false // MARK: - Lifecycle @@ -69,6 +76,21 @@ class BleRestoreManager: NSObject { self.saveBandUUID(uuid) self.bandUUID = uuid self.handedOff = false + self.idleAfterSync = false // explicit (re-)arm request from Dart + self.armIfAppropriate() + } + result(nil) + case "setOwnsBand": + let owns = (call.arguments as? Bool) ?? false + self.appOwnsBand = owns + if owns { + // App reclaimed the band — drop our pending connect so the two centrals don't fight. + self.cancelPending() + NSLog("[ble-restore] app owns band — pending connect cancelled") + } else { + // App released the band (connection dropped in background) — arm recovery. + self.idleAfterSync = false // explicit re-arm request from Dart + NSLog("[ble-restore] app released band — arming recovery") self.armIfAppropriate() } result(nil) @@ -83,10 +105,12 @@ class BleRestoreManager: NSObject { } result(nil) case "syncDone": - // Dart finished the headless drain — pace the next one via cooldown. + // Dart finished the headless drain. Go idle (no re-arm) until the next explicit + // arm from Dart — prevents a reconnect-drain loop with no timer/cooldown. self.handedOff = false + self.idleAfterSync = true + self.cancelPending() self.endBackground() - self.beginCooldown() result(nil) default: result(FlutterMethodNotImplemented) @@ -104,11 +128,21 @@ class BleRestoreManager: NSObject { // MARK: - Pending connect private func armIfAppropriate() { - guard !handedOff, !coolingDown, - let central = central, central.state == .poweredOn, - let uuid = bandUUID else { return } + // The app holds the live connection — don't arm a competing connect. + if appOwnsBand { NSLog("[ble-restore] skip arm — app owns band"); return } + // Log every guard-fail reason: an unexplained no-arm is why background relaunch + // silently never happened (the band reappeared but nothing was pending to wake us). + guard !handedOff else { NSLog("[ble-restore] skip arm — handedOff"); return } + guard !idleAfterSync else { NSLog("[ble-restore] skip arm — idle after sync (awaiting re-arm)"); return } + guard let central = central else { NSLog("[ble-restore] skip arm — no central"); return } + guard central.state == .poweredOn else { + NSLog("[ble-restore] skip arm — central not poweredOn (state=\(central.state.rawValue))"); return + } + guard let uuid = bandUUID else { NSLog("[ble-restore] skip arm — no bandUUID"); return } // In the foreground, flutter_blue_plus owns the band — don't compete. - if UIApplication.shared.applicationState == .active { return } + if UIApplication.shared.applicationState == .active { + NSLog("[ble-restore] skip arm — app active"); return + } guard let p = central.retrievePeripherals(withIdentifiers: [uuid]).first else { NSLog("[ble-restore] band not retrievable yet") return @@ -123,23 +157,8 @@ class BleRestoreManager: NSObject { pending = nil } - private func beginCooldown() { - coolingDown = true - cancelPending() - cooldownWork?.cancel() - let work = DispatchWorkItem { [weak self] in - guard let self = self else { return } - self.coolingDown = false - self.armIfAppropriate() - } - cooldownWork = work - DispatchQueue.main.asyncAfter(deadline: .now() + cooldownSeconds, execute: work) - NSLog("[ble-restore] cooldown \(Int(cooldownSeconds))s before next arm") - } - private func disarm() { - cooldownWork?.cancel() - coolingDown = false + idleAfterSync = false handedOff = false cancelPending() clearBandUUID() @@ -158,14 +177,16 @@ class BleRestoreManager: NSObject { wakeQueuedBeforeReady = true NSLog("[ble-restore] wake queued (Flutter not ready)") } - // Safety net: if Dart never calls syncDone (crash/timeout), cooldown anyway so we - // neither loop nor get stuck. + // Watchdog: if Dart never calls syncDone (crash), clear the handoff so we don't get + // stuck, and go idle (await an explicit re-arm) so we don't loop. Not a sync cadence — + // just a failsafe to release the in-flight state. DispatchQueue.main.asyncAfter(deadline: .now() + 60) { [weak self] in guard let self = self, self.handedOff else { return } - NSLog("[ble-restore] syncDone timeout — cooling down") + NSLog("[ble-restore] syncDone watchdog fired — releasing handoff, going idle") self.handedOff = false + self.idleAfterSync = true + self.cancelPending() self.endBackground() - self.beginCooldown() } } @@ -219,11 +240,13 @@ extension BleRestoreManager: CBCentralManagerDelegate { func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { NSLog("[ble-restore] didDisconnect (handedOff=\(handedOff))") - if !handedOff && !coolingDown { armIfAppropriate() } + // Re-arm only if our own pending connect dropped while still in recovery mode (band + // went away again). armIfAppropriate's idleAfterSync/appOwnsBand guards prevent loops. + if !handedOff { armIfAppropriate() } } func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { NSLog("[ble-restore] didFailToConnect: \(error?.localizedDescription ?? "—")") - if !handedOff && !coolingDown { armIfAppropriate() } + if !handedOff { armIfAppropriate() } } } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 7fd12775d..2a833b30d 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -4,11 +4,6 @@ NSSupportsLiveActivities - BGTaskSchedulerPermittedIdentifiers - - openstrap.periodicSync - be.tramckrijte.workmanager.BackgroundTask - CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion @@ -63,9 +58,6 @@ UIBackgroundModes bluetooth-central - processing - fetch - bluetooth-peripheral remote-notification UILaunchStoryboardName diff --git a/lib/ble/ios_ble_restore.dart b/lib/ble/ios_ble_restore.dart index 77e291e44..dd49990c7 100644 --- a/lib/ble/ios_ble_restore.dart +++ b/lib/ble/ios_ble_restore.dart @@ -5,7 +5,8 @@ // even from terminated. When that fires, native invokes `wake` here and we run the same // headless drain the periodic task uses, then tell native we're done so it re-arms. // -// No-op on Android (WorkManager handles background there). +// No-op on Android (the Edge Tracking foreground service keeps the process + live +// connection alive there — no restore central needed). import 'dart:io'; @@ -49,6 +50,17 @@ class IosBleRestore { } catch (_) {} } + /// Tell native whether the app currently owns the live connection (via + /// flutter_blue_plus). While true, the restore central must NOT arm a competing + /// pending connect to the same peripheral. Set false (then [arm]) to hand the band + /// to the restore path for background relaunch. + static Future setOwnsBand(bool owns) async { + if (!Platform.isIOS) return; + try { + await _ch.invokeMethod('setOwnsBand', owns); + } catch (_) {} + } + /// Arm restoration for this band (its iOS peripheral UUID == PairedDevice.remoteId). static Future arm(String remoteId) async { if (!Platform.isIOS) return; diff --git a/lib/main.dart b/lib/main.dart index 9fd6941ce..39b41aba8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,14 +4,10 @@ import 'package:provider/provider.dart'; import 'app.dart'; import 'ble/ios_ble_restore.dart'; import 'state/app_state.dart'; -import 'sync/background_sync.dart'; import 'widget/widget_service.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - // Register the background-sync isolate entry point (no-op if the platform - // task never fires). Safe to call before runApp. - await BackgroundSync.init(); // iOS: register the CoreBluetooth-restoration wake handler. On a background // relaunch this runs too, so a band-triggered wake reaches runHeadlessSync. await IosBleRestore.init(); diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 1db9bf3c4..a44ecd710 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -20,7 +20,6 @@ import '../data/db.dart'; import '../data/models.dart'; import '../net/api_client.dart'; import '../live/live_activity.dart'; -import '../sync/background_sync.dart'; import '../sync/config.dart'; import '../sync/edge_tracking.dart'; import '../widget/widget_service.dart'; @@ -46,6 +45,20 @@ class AppState extends ChangeNotifier { String _prevConn = 'disconnected'; bool initialized = false; + /// True while the app is backgrounded. On iOS we KEEP the BLE connection alive in + /// this state (see [pauseForBackground]) so the OS keeps resuming us per BLE + /// notification and the live drain + flush continue. + bool _background = false; + + /// One session-long flusher. It uploads pending raw records on a steady cadence and, + /// because the uploader RETAINS rows on any non-200 (a transient rate-limit 429 + /// included), each tick also retries whatever the last tick couldn't send. No + /// per-record flushing and no backoff/cooldown — a plain ~15s cadence sits well under + /// the backend rate limit (burst 30, refill 0.5/s) on its own. On iOS the timer simply + /// fires on the next BLE-notification resume when the app was suspended. + Timer? _flushTimer; + static const Duration _kFlushInterval = Duration(seconds: 15); + bool get backendChosen => config?.chosen ?? false; bool get isAuthenticated => session?.isValid ?? false; bool get isPaired => paired != null; @@ -82,6 +95,11 @@ class AppState extends ChangeNotifier { _savedAlarm = (await SharedPreferences.getInstance()).getInt('alarm_epoch'); initialized = true; notifyListeners(); + // The flusher is connection-INDEPENDENT: it just uploads whatever's queued in + // SQLite (and retries anything a prior tick failed to send). Start it as soon as + // we're authenticated so a backlog drains even if the live connection comes up via + // _reconnect rather than openSession. + if (isAuthenticated) _startFlusher(); if (isAuthenticated && isPaired) openSession(); } @@ -94,6 +112,7 @@ class AppState extends ChangeNotifier { // Refresh failed — session already cleared by ApiClient. Drop to login. // The local upload queue persists and retries after re-login. _keepAlive = false; + _stopFlusher(); engine.disconnect(); _log('Session expired — please sign in again.'); notifyListeners(); @@ -139,6 +158,7 @@ class AppState extends ChangeNotifier { /// Verify OTP → session persisted by ApiClient. Returns true on success. Future verifyOtp(String email, String code) async { await api!.verifyOtp(email, code); + _startFlusher(); notifyListeners(); } @@ -150,8 +170,8 @@ class AppState extends ChangeNotifier { Future signOut() async { _keepAlive = false; + _stopFlusher(); IosBleRestore.foregroundActive = false; - await BackgroundSync.disable(); await EdgeTracking.stop(); await IosBleRestore.disarm(); await engine.disconnect(); @@ -159,31 +179,77 @@ class AppState extends ChangeNotifier { notifyListeners(); } - /// Called when the app goes to the background. On iOS this hands the band to the - /// CoreBluetooth restoration path: clear the foreground flag (the wake-drain is gated - /// on it) and release our live connection so the native restore central's no-timeout - /// pending connect can hold it and relaunch us when the band is reachable. Without - /// this, `foregroundActive` stays true for the whole session and the background wake - /// never drains — sync only ever happened on app open. + /// Called when the app goes to the background. + /// + /// iOS keeps an app alive in the background ONLY while it holds an active BLE + /// connection with a subscribed characteristic (UIBackgroundModes: bluetooth-central). + /// So we DELIBERATELY keep the live connection + streams up here instead of + /// disconnecting — the band keeps pushing notifications, iOS resumes us per + /// notification, and the drain+upload continue continuously. (The old code called + /// `engine.disconnect()` here, which made iOS drop the Bluetooth assertion and suspend + /// us within ~34s, so sync only ever ran when the app was reopened.) /// - /// On Android we do NOT disconnect: the Edge Tracking foreground service keeps the - /// process + connection alive, so the live drain just continues. + /// We still own the band, so the restore central must NOT arm a competing connect. + /// `BleRestoreManager` is armed only as a RECOVERY path if the connection actually + /// drops (band out of range / app jettisoned) — see [_onEngineState] / [_armRecovery]. + /// + /// On Android the Edge Tracking foreground service keeps the process + connection + /// alive, so the live drain just continues there too. Future pauseForBackground() async { + _background = true; + if (Platform.isAndroid) { + // Android: ensure the Edge Tracking foreground service is up (idempotent) so the + // process + live connection survive backgrounding; the shared flusher keeps + // uploading. No periodic task, no restore central — the service IS the keep-alive. + EdgeTracking.start(); + return; + } if (!Platform.isIOS) return; + if (engine.isConnected) { + IosBleRestore.foregroundActive = true; // "app owns the band" — don't let restore compete + await IosBleRestore.setOwnsBand(true); + _log('Backgrounded — holding live connection for continuous background sync'); + } else { + // No live connection to hold — fall back to the restore path so iOS relaunches us + // when the band reappears. + await _armRecovery(); + _log('Backgrounded — no live connection; armed iOS restore recovery'); + } + } + + /// iOS recovery: release the band to the native restore central's no-timeout pending + /// connect so the OS relaunches us when the band is reachable again. + Future _armRecovery() async { + if (!Platform.isIOS || paired == null) return; IosBleRestore.foregroundActive = false; - _keepAlive = false; // stop the drop handler from auto-reconnecting and fighting the handoff - await engine.disconnect(); - _log('Backgrounded — released band to iOS restore for background sync'); + await IosBleRestore.setOwnsBand(false); + await IosBleRestore.arm(paired!.remoteId); } Future _onRecord(Sample? sample, RawRecord raw) async { await LocalDb.insertRecord(raw, sample); } + /// Start the session-long flusher (idempotent). Cancelled on disconnect / sign-out. + void _startFlusher() { + _flushTimer ??= Timer.periodic(_kFlushInterval, (_) { + if (!uploading) unawaited(upload()); + }); + } + + void _stopFlusher() { + _flushTimer?.cancel(); + _flushTimer = null; + } + void _onEngineState(DeviceState s) { if (_prevConn != 'disconnected' && s.connection == 'disconnected') { if (_keepAlive && isPaired && !_reconnecting) { _log('Connection dropped — reconnecting…'); + // If we're backgrounded, also arm the iOS restore path: if the in-process + // reconnect can't reach the band (out of range / about to be jettisoned), the + // OS will relaunch us when it returns. + if (_background) unawaited(_armRecovery()); _reconnect(); } } @@ -210,8 +276,8 @@ class AppState extends ChangeNotifier { Future unpair() async { _keepAlive = false; + _stopFlusher(); IosBleRestore.foregroundActive = false; - await BackgroundSync.disable(); await EdgeTracking.stop(); await IosBleRestore.disarm(); await engine.disconnect(); @@ -261,11 +327,21 @@ class AppState extends ChangeNotifier { // ── session: drain history, go live, stay connected ────────────────────────── Future openSession() async { if (busy || paired == null || !isAuthenticated) return; + // Returning to the foreground with the connection still alive (kept during + // background): don't tear it down and reconnect — just reclaim ownership and flush. + final wasBackground = _background; + _background = false; + if (wasBackground && engine.isConnected) { + IosBleRestore.foregroundActive = true; + await IosBleRestore.setOwnsBand(true); + EdgeTracking.start(); // Android: keep the foreground service up (idempotent) + _startFlusher(); + unawaited(upload()); + return; + } _setBusy(true); lastError = null; _keepAlive = true; - // Keep syncing in the background. Idempotent — safe to call on every session start. - BackgroundSync.enable(); // Android: start the Edge Tracking foreground service so the live connection keeps // draining while backgrounded (Android kills background processes otherwise). EdgeTracking.start(); @@ -286,13 +362,11 @@ class AppState extends ChangeNotifier { await engine.getAlarm(); _log('Live session active.'); - final flush = Timer.periodic(const Duration(seconds: 15), (_) => upload()); - late final SyncReport report; - try { - report = await engine.runSync(); - } finally { - flush.cancel(); - } + // Start the session-long flusher (it keeps running after the drain, flushing live + // records + retrying anything a tick failed to send). Replaces the old + // drain-scoped timer that stopped once history finished. + _startFlusher(); + final report = await engine.runSync(); _log('Drained ${report.records} records in ${report.batches} batches ' '(${report.complete ? "complete" : "idle-stopped"}).'); dbCounts = await LocalDb.counts(); @@ -312,6 +386,13 @@ class AppState extends ChangeNotifier { await Future.delayed(Duration(seconds: 2 * attempt)); if (!_keepAlive) break; if (await engine.connectToRemoteId(paired!.remoteId)) { + // Reclaim the band from the iOS restore central so it stops competing. + if (Platform.isIOS) { + IosBleRestore.foregroundActive = true; + await IosBleRestore.setOwnsBand(true); + } + _startFlusher(); // ensure live uploads run even on a reconnect-only path + EdgeTracking.start(); // ensure the Android foreground service is up too await engine.runSync(timeout: const Duration(seconds: 30)); await upload(); await engine.enableLiveStreams(); @@ -330,6 +411,7 @@ class AppState extends ChangeNotifier { Future endSession() async { _keepAlive = false; + _stopFlusher(); await engine.disconnect(); } @@ -364,7 +446,10 @@ class AppState extends ChangeNotifier { notifyListeners(); }); if (result.ok) { - _log('Uploaded ${result.accepted}/${result.attempted} records.'); + // Suppress the every-tick "0/0" noise — the flusher polls on a steady cadence. + if (result.attempted > 0) { + _log('Uploaded ${result.accepted}/${result.attempted} records.'); + } } else { lastError = 'Upload failed: ${result.error}'; _log(lastError!); diff --git a/lib/sync/background_sync.dart b/lib/sync/background_sync.dart index 9686fff36..8f5eaa16b 100644 --- a/lib/sync/background_sync.dart +++ b/lib/sync/background_sync.dart @@ -1,15 +1,17 @@ -// Background sync — runs the connect → drain → upload flow with NO UI, NO -// Provider, NO foreground service / sticky notification. "Comes, does its job, -// goes." Reused by the OS periodic scheduler (WorkManager on Android, BGTask on -// iOS via the workmanager plugin) and callable directly. +// Headless sync — runs the connect → drain → upload flow with NO UI, NO Provider. +// "Comes, does its job, goes." Invoked by the iOS CoreBluetooth-restoration RECOVERY +// path (ios_ble_restore.dart) when the band reappears after the live connection dropped. +// +// There is NO OS periodic scheduler anymore (no WorkManager 15-min task, no BGTask): +// continuous sync is the kept-alive live connection + the persistent flusher in +// AppState. This is purely the relaunch-recovery fallback. // // Connectivity-agnostic by design: it does NOT assume the strap stays connected. -// Each run just connects-by-id if reachable, drains whatever the band buffered to -// flash (non-destructive cursor — catches up everything since last time), uploads, -// and disconnects. A missed window is harmless; the next run catches up. +// It connects-by-id if reachable, drains whatever the band buffered to flash +// (non-destructive cursor — catches up everything since last time), uploads, and +// disconnects. A missed run is harmless; the next reconnect catches up. import 'package:flutter/widgets.dart'; -import 'package:workmanager/workmanager.dart'; import '../ble/ble_engine.dart'; import '../data/db.dart'; @@ -17,11 +19,7 @@ import '../net/api_client.dart'; import 'config.dart'; import 'uploader.dart'; -/// Unique name + tag for the periodic OS task. -const String _kPeriodicTask = 'openstrap.periodicSync'; - -/// One headless sync pass. Safe to call from a background isolate. Never throws — -/// returns true so the OS scheduler treats the run as handled (no thrash-retry). +/// One headless sync pass. Safe to call from a background isolate. Never throws. Future runHeadlessSync() async { WidgetsFlutterBinding.ensureInitialized(); try { @@ -69,38 +67,3 @@ Future runHeadlessSync() async { return true; } } - -/// WorkManager/BGTask entry point. MUST be a top-level function annotated for the -/// AOT compiler so the background isolate can find it. -@pragma('vm:entry-point') -void callbackDispatcher() { - Workmanager().executeTask((task, inputData) => runHeadlessSync()); -} - -/// Thin facade over the OS scheduler. -class BackgroundSync { - /// Call once at app start (registers the isolate entry point). - static Future init() async { - await Workmanager().initialize(callbackDispatcher); - } - - /// Schedule the periodic background sync. 15 min is the OS floor; the OS may run - /// it less often (Doze / iOS throttling) — fine, since the drain catches up. - /// Requires network; idempotent (keep existing if already scheduled). - static Future enable() async { - await Workmanager().registerPeriodicTask( - _kPeriodicTask, - _kPeriodicTask, - frequency: const Duration(minutes: 15), - constraints: Constraints(networkType: NetworkType.connected), - existingWorkPolicy: ExistingPeriodicWorkPolicy.keep, - backoffPolicy: BackoffPolicy.linear, - backoffPolicyDelay: const Duration(minutes: 5), - ); - } - - /// Stop background sync (on sign-out / unpair). - static Future disable() async { - await Workmanager().cancelByUniqueName(_kPeriodicTask); - } -} diff --git a/lib/sync/uploader.dart b/lib/sync/uploader.dart index b7f795003..f111629aa 100644 --- a/lib/sync/uploader.dart +++ b/lib/sync/uploader.dart @@ -30,7 +30,12 @@ class Uploader { attempted += batch.length; try { final body = await api.ingestBatch(batch.map((r) => r.hex).toList()); - accepted += (body['processed'] as int?) ?? 0; + // The backend persists every record raw (R2 = system of record) and returns + // a count. Older builds returned no count field; fall back to the batch size + // since a 2xx means the server received + stored them all. + accepted += (body['received'] as int?) ?? + (body['processed'] as int?) ?? + batch.length; await LocalDb.markUploaded(batch.map((r) => r.hex).toList()); if (onChunk != null) await onChunk(); } catch (e) { diff --git a/pubspec.lock b/pubspec.lock index 802c0c4d9..1eb6619fb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -981,38 +981,6 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" - workmanager: - dependency: "direct main" - description: - name: workmanager - sha256: "065673b2a465865183093806925419d311a9a5e0995aa74ccf8920fd695e2d10" - url: "https://pub.dev" - source: hosted - version: "0.9.0+3" - workmanager_android: - dependency: transitive - description: - name: workmanager_android - sha256: "9ae744db4ef891f5fcd2fb8671fccc712f4f96489a487a1411e0c8675e5e8cb7" - url: "https://pub.dev" - source: hosted - version: "0.9.0+2" - workmanager_apple: - dependency: transitive - description: - name: workmanager_apple - sha256: "1cc12ae3cbf5535e72f7ba4fde0c12dd11b757caf493a28e22d684052701f2ca" - url: "https://pub.dev" - source: hosted - version: "0.9.1+2" - workmanager_platform_interface: - dependency: transitive - description: - name: workmanager_platform_interface - sha256: f40422f10b970c67abb84230b44da22b075147637532ac501729256fcea10a47 - url: "https://pub.dev" - source: hosted - version: "0.9.1+1" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e7801752f..bbebbfbc8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,10 +36,6 @@ dependencies: # Share recap cards (rendered card → image → share sheet). share_plus: ^10.1.4 - # Background sync — OS-scheduled periodic task (Android WorkManager / iOS BGTask). - # No foreground service, no persistent notification. - workmanager: ^0.9.0 - # Home/lock-screen widget bridge (writes a snapshot to the App Group → WidgetKit). home_widget: ^0.6.0