From a3828bc877321b52b3f1435feca08460b87464d0 Mon Sep 17 00:00:00 2001 From: Siddarth Kumar Date: Thu, 9 Jul 2026 18:12:04 +0530 Subject: [PATCH] ios: implement continuous sync setting --- .../syncup/GoServerBridgeModule.kt | 11 ++ .../com/siddarthkay/syncup/SyncthingPrefs.kt | 12 ++ .../ios/syncup.xcodeproj/project.pbxproj | 4 + mobile-app/ios/syncup/AppDelegate.swift | 6 + mobile-app/ios/syncup/BackgroundManager.swift | 63 ++++++- mobile-app/ios/syncup/GoBridgeWrapper.h | 2 + mobile-app/ios/syncup/GoBridgeWrapper.mm | 14 ++ mobile-app/ios/syncup/GoServerBridge.h | 2 + mobile-app/ios/syncup/GoServerBridge.mm | 8 + mobile-app/ios/syncup/Info.plist | 2 + mobile-app/ios/syncup/KeepAliveManager.swift | 174 ++++++++++++++++++ mobile-app/src/GoServerBridgeJSI.ts | 10 + mobile-app/src/NativeGoServerBridge.ts | 2 + mobile-app/src/screens/SettingsScreen.tsx | 47 +++++ 14 files changed, 353 insertions(+), 4 deletions(-) create mode 100644 mobile-app/ios/syncup/KeepAliveManager.swift diff --git a/mobile-app/android/app/src/main/java/com/siddarthkay/syncup/GoServerBridgeModule.kt b/mobile-app/android/app/src/main/java/com/siddarthkay/syncup/GoServerBridgeModule.kt index 8662ab7..226c222 100644 --- a/mobile-app/android/app/src/main/java/com/siddarthkay/syncup/GoServerBridgeModule.kt +++ b/mobile-app/android/app/src/main/java/com/siddarthkay/syncup/GoServerBridgeModule.kt @@ -171,6 +171,17 @@ class GoServerBridgeModule(reactContext: ReactApplicationContext) : return true } + // iOS-only behaviour; on Android the foreground service already keeps the + // daemon resident, so we just persist the flag for settings parity. + override fun getContinuousBackgroundSync(): Boolean { + return SyncthingPrefs.getContinuousBackgroundSync(ctx) + } + + override fun setContinuousBackgroundSync(enabled: Boolean): Boolean { + SyncthingPrefs.setContinuousBackgroundSync(ctx, enabled) + return SyncthingPrefs.getContinuousBackgroundSync(ctx) + } + override fun getChargingOnlySync(): Boolean { return SyncthingPrefs.getChargingOnlySync(ctx) } diff --git a/mobile-app/android/app/src/main/java/com/siddarthkay/syncup/SyncthingPrefs.kt b/mobile-app/android/app/src/main/java/com/siddarthkay/syncup/SyncthingPrefs.kt index 1045bfd..429509d 100644 --- a/mobile-app/android/app/src/main/java/com/siddarthkay/syncup/SyncthingPrefs.kt +++ b/mobile-app/android/app/src/main/java/com/siddarthkay/syncup/SyncthingPrefs.kt @@ -12,6 +12,7 @@ object SyncthingPrefs { private const val KEY_ALLOW_MOBILE_DATA = "allow_mobile_data" private const val KEY_EXTERNAL_CONTROL = "external_control_enabled" private const val KEY_START_ON_BOOT = "start_on_boot_enabled" + private const val KEY_CONTINUOUS_BG_SYNC = "continuous_background_sync" private fun prefs(context: Context): SharedPreferences = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) @@ -64,6 +65,16 @@ object SyncthingPrefs { prefs(context).edit().putBoolean(KEY_START_ON_BOOT, value).apply() } + // iOS-only behaviour (silent-audio keep-alive). On Android the foreground + // service already keeps the daemon resident, so this is just a persisted + // flag kept for cross-platform parity of the settings surface. + fun getContinuousBackgroundSync(context: Context): Boolean = + prefs(context).getBoolean(KEY_CONTINUOUS_BG_SYNC, false) + + fun setContinuousBackgroundSync(context: Context, value: Boolean) { + prefs(context).edit().putBoolean(KEY_CONTINUOUS_BG_SYNC, value).apply() + } + private val backupKeys = listOf( KEY_WIFI_ONLY_SYNC, KEY_CHARGING_ONLY_SYNC, @@ -71,6 +82,7 @@ object SyncthingPrefs { KEY_ALLOW_MOBILE_DATA, KEY_EXTERNAL_CONTROL, KEY_START_ON_BOOT, + KEY_CONTINUOUS_BG_SYNC, ) fun exportAsJson(context: Context): String { diff --git a/mobile-app/ios/syncup.xcodeproj/project.pbxproj b/mobile-app/ios/syncup.xcodeproj/project.pbxproj index fba921b..9294276 100644 --- a/mobile-app/ios/syncup.xcodeproj/project.pbxproj +++ b/mobile-app/ios/syncup.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ BP01234567890ABC12345002 /* BackupPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = BP01234567890ABC12345001 /* BackupPicker.swift */; }; F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; }; G0S1234567890ABC12345678 /* GoServerBridge.mm in Sources */ = {isa = PBXBuildFile; fileRef = G0S1234567890ABC12345679 /* GoServerBridge.mm */; }; + KA01234567890ABC12345002 /* KeepAliveManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = KA01234567890ABC12345001 /* KeepAliveManager.swift */; }; QL01234567890ABC12345002 /* QuickLookPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = QL01234567890ABC12345001 /* QuickLookPresenter.swift */; }; SF01234567890ABC12345002 /* ScopedFolderStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = SF01234567890ABC12345001 /* ScopedFolderStore.swift */; }; /* End PBXBuildFile section */ @@ -45,6 +46,7 @@ FB0085F001083A8C91ADF71C /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = syncup/PrivacyInfo.xcprivacy; sourceTree = ""; }; G0S1234567890ABC12345679 /* GoServerBridge.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; name = GoServerBridge.mm; path = syncup/GoServerBridge.mm; sourceTree = ""; }; G0S1234567890ABC12345680 /* GoServerBridge.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = GoServerBridge.h; path = syncup/GoServerBridge.h; sourceTree = ""; }; + KA01234567890ABC12345001 /* KeepAliveManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = KeepAliveManager.swift; path = syncup/KeepAliveManager.swift; sourceTree = ""; }; QL01234567890ABC12345001 /* QuickLookPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = QuickLookPresenter.swift; path = syncup/QuickLookPresenter.swift; sourceTree = ""; }; SF01234567890ABC12345001 /* ScopedFolderStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScopedFolderStore.swift; path = syncup/ScopedFolderStore.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -68,6 +70,7 @@ BM01234567890ABC12345001 /* BackgroundManager.swift */, BM01234567890ABC12345003 /* BackgroundErrorNotifier.swift */, BM01234567890ABC12345005 /* AppShortcuts.swift */, + KA01234567890ABC12345001 /* KeepAliveManager.swift */, G0S1234567890ABC12345680 /* GoServerBridge.h */, G0S1234567890ABC12345679 /* GoServerBridge.mm */, SF01234567890ABC12345001 /* ScopedFolderStore.swift */, @@ -361,6 +364,7 @@ BM01234567890ABC12345002 /* BackgroundManager.swift in Sources */, BM01234567890ABC12345004 /* BackgroundErrorNotifier.swift in Sources */, BM01234567890ABC12345006 /* AppShortcuts.swift in Sources */, + KA01234567890ABC12345002 /* KeepAliveManager.swift in Sources */, G0S1234567890ABC12345678 /* GoServerBridge.mm in Sources */, 949EB339D788314DE425296D /* ExpoModulesProvider.swift in Sources */, 0C823C245A529D0F8E0CEB71 /* GoBridgeWrapper.mm in Sources */, diff --git a/mobile-app/ios/syncup/AppDelegate.swift b/mobile-app/ios/syncup/AppDelegate.swift index 0d90ff2..f7d6c41 100644 --- a/mobile-app/ios/syncup/AppDelegate.swift +++ b/mobile-app/ios/syncup/AppDelegate.swift @@ -22,6 +22,7 @@ public class AppDelegate: ExpoAppDelegate { BackgroundManager.shared.register() BackgroundManager.shared.scheduleNext() + KeepAliveManager.shared.applyFromPreference() // Request notification authorization at foreground so the BG path // (folder errors, stale vault watchdog) never has to prompt — BG tasks // can't show UI, and the lazy prompt would silently no-op. @@ -60,10 +61,15 @@ public class AppDelegate: ExpoAppDelegate { super.applicationDidEnterBackground(application) // re-submit request on every backgrounding; iOS drops stale ones. BackgroundManager.shared.scheduleNext() + // Hold the ~30s guaranteed window so a sync started just before backgrounding + // (e.g. a file the user just created) finishes pushing instead of freezing. + BackgroundManager.shared.startBackgroundLinger() + KeepAliveManager.shared.applyFromPreference() } public override func applicationWillEnterForeground(_ application: UIApplication) { super.applicationWillEnterForeground(application) + BackgroundManager.shared.cancelBackgroundLinger() // BG task may have drained the daemon (SQLite WAL); restart before first REST call lands. _ = GoBridgeWrapper.startServer() } diff --git a/mobile-app/ios/syncup/BackgroundManager.swift b/mobile-app/ios/syncup/BackgroundManager.swift index c8df710..ba80bd5 100644 --- a/mobile-app/ios/syncup/BackgroundManager.swift +++ b/mobile-app/ios/syncup/BackgroundManager.swift @@ -17,26 +17,37 @@ import UIKit private var expireTimer: Timer? private var currentTask: BGTask? + // Linger window (beginBackgroundTask) held across a foreground->background transition. + private var lingerTaskId: UIBackgroundTaskIdentifier = .invalid + private var lingerTimer: Timer? + private override init() { super.init() } /// Must run from didFinishLaunching, registering late means iOS rejects every task. @objc func register() { - BGTaskScheduler.shared.register( + let shortOK = BGTaskScheduler.shared.register( forTaskWithIdentifier: TaskKind.short.rawValue, using: DispatchQueue.main ) { [weak self] task in self?.handle(task: task) } - BGTaskScheduler.shared.register( + let longOK = BGTaskScheduler.shared.register( forTaskWithIdentifier: TaskKind.long.rawValue, using: DispatchQueue.main ) { [weak self] task in self?.handle(task: task) } - NSLog("BackgroundManager: registered %@ and %@", - TaskKind.short.rawValue, TaskKind.long.rawValue) + NSLog("BackgroundManager: registered short=%@ (%@), long=%@ (%@)", + shortOK ? "ok" : "FAILED", TaskKind.short.rawValue, + longOK ? "ok" : "FAILED", TaskKind.long.rawValue) + if !shortOK { + NSLog("BackgroundManager: short task registration FAILED - is 'fetch' in UIBackgroundModes?") + } + if !longOK { + NSLog("BackgroundManager: long task registration FAILED - is 'processing' in UIBackgroundModes?") + } } /// earliestBeginDate is a hint, not a promise. iOS decides when (or if) to actually fire. @@ -62,6 +73,50 @@ import UIKit } } + // MARK: - Background linger + + @objc func startBackgroundLinger() { + // never stack lingers; a re-background while one is live just resets the window. + endLinger() + + let app = UIApplication.shared + lingerTaskId = app.beginBackgroundTask(withName: "com.siddarthkay.syncup.linger") { [weak self] in + NSLog("BackgroundManager: linger expiration handler fired") + self?.endLinger() + } + guard lingerTaskId != .invalid else { + NSLog("BackgroundManager: linger not granted by iOS - skipping") + return + } + + _ = GoBridgeWrapper.startServer() + NSLog("BackgroundManager: linger started, remaining=%.1f", app.backgroundTimeRemaining) + + lingerTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + let remaining = UIApplication.shared.backgroundTimeRemaining + if remaining <= BackgroundManager.backgroundTimeReserve { + NSLog("BackgroundManager: linger within reserve (%.1fs) - ending", + BackgroundManager.backgroundTimeReserve) + self?.endLinger() + } + } + } + + /// Cancel the linger early (e.g. the user foregrounded before the window elapsed). + @objc func cancelBackgroundLinger() { + endLinger() + } + + private func endLinger() { + lingerTimer?.invalidate() + lingerTimer = nil + guard lingerTaskId != .invalid else { return } + let id = lingerTaskId + lingerTaskId = .invalid + NSLog("BackgroundManager: ending linger task") + UIApplication.shared.endBackgroundTask(id) + } + // MARK: - Task handling private func handle(task: BGTask) { diff --git a/mobile-app/ios/syncup/GoBridgeWrapper.h b/mobile-app/ios/syncup/GoBridgeWrapper.h index b455fa8..f8faaf7 100644 --- a/mobile-app/ios/syncup/GoBridgeWrapper.h +++ b/mobile-app/ios/syncup/GoBridgeWrapper.h @@ -20,6 +20,8 @@ + (BOOL)setWifiOnlySync:(BOOL)enabled; + (BOOL)getChargingOnlySync; + (BOOL)setChargingOnlySync:(BOOL)enabled; ++ (BOOL)getContinuousBackgroundSync; ++ (BOOL)setContinuousBackgroundSync:(BOOL)enabled; + (BOOL)openBatteryOptimizationSettings; + (BOOL)isIgnoringBatteryOptimizations; + (BOOL)openFolderInFileManager:(NSString *)path; diff --git a/mobile-app/ios/syncup/GoBridgeWrapper.mm b/mobile-app/ios/syncup/GoBridgeWrapper.mm index f723f32..9b2a5a5 100644 --- a/mobile-app/ios/syncup/GoBridgeWrapper.mm +++ b/mobile-app/ios/syncup/GoBridgeWrapper.mm @@ -31,6 +31,12 @@ - (NSString * _Nonnull)exportFileBlockingWithSourcePath:(NSString * _Nonnull)sou - (NSString * _Nonnull)importFileBlockingWithDestinationPath:(NSString * _Nonnull)destinationPath; @end +@interface KeepAliveManager : NSObject ++ (instancetype _Nonnull)shared; ++ (BOOL)isEnabled; +- (void)setEnabled:(BOOL)enabled; +@end + static NSString * const kNotifiedErrorCountsKey = @"com.siddarthkay.syncup.notifiedErrorCounts"; static NSString * const kVaultRegistryKey = @"com.siddarthkay.syncup.vaultRegistry"; static NSString * const kNotifiedVaultStaleKey = @"com.siddarthkay.syncup.notifiedVaultStale"; @@ -262,6 +268,14 @@ + (BOOL)getWifiOnlySync { return NO; } + (BOOL)setWifiOnlySync:(BOOL)enabled { return NO; } + (BOOL)getChargingOnlySync { return NO; } + (BOOL)setChargingOnlySync:(BOOL)enabled { return NO; } + ++ (BOOL)getContinuousBackgroundSync { + return [KeepAliveManager isEnabled]; +} ++ (BOOL)setContinuousBackgroundSync:(BOOL)enabled { + [[KeepAliveManager shared] setEnabled:enabled]; + return [KeepAliveManager isEnabled]; +} + (BOOL)openBatteryOptimizationSettings { return NO; } + (BOOL)isIgnoringBatteryOptimizations { return YES; } + (BOOL)openFolderInFileManager:(NSString *)path { return NO; } diff --git a/mobile-app/ios/syncup/GoServerBridge.h b/mobile-app/ios/syncup/GoServerBridge.h index 0e732b9..c785c3d 100644 --- a/mobile-app/ios/syncup/GoServerBridge.h +++ b/mobile-app/ios/syncup/GoServerBridge.h @@ -27,6 +27,8 @@ class GoServerBridgeImpl : public facebook::react::NativeGoServerBridgeCxxSpec UIBackgroundModes + fetch processing + audio UIFileSharingEnabled diff --git a/mobile-app/ios/syncup/KeepAliveManager.swift b/mobile-app/ios/syncup/KeepAliveManager.swift new file mode 100644 index 0000000..fb85b5e --- /dev/null +++ b/mobile-app/ios/syncup/KeepAliveManager.swift @@ -0,0 +1,174 @@ +import AVFoundation +import Foundation + +@objc(KeepAliveManager) final class KeepAliveManager: NSObject { + + @objc static let shared = KeepAliveManager() + + /// UserDefaults key. Mirrors the JS setting so the toggle, launch, and + /// background paths all read the same source of truth. + @objc static let preferenceKey = "syncthing.continuousBackgroundSync" + + private let engine = AVAudioEngine() + private let player = AVAudioPlayerNode() + private var silentBuffer: AVAudioPCMBuffer? + private var running = false + private var observing = false + + private override init() { + super.init() + } + + // MARK: - Preference + + @objc static var isEnabled: Bool { + UserDefaults.standard.bool(forKey: preferenceKey) + } + + /// Persist the toggle and immediately reconcile the running state. + @objc func setEnabled(_ enabled: Bool) { + UserDefaults.standard.set(enabled, forKey: KeepAliveManager.preferenceKey) + applyFromPreference() + } + + /// Reconcile the actual audio state with the stored preference. + @objc func applyFromPreference() { + runOnMain { [weak self] in self?.reconcile() } + } + + /// Bring the audio state in line with the preference. Always on main. + private func reconcile() { + if KeepAliveManager.isEnabled { + if running && engine.isRunning { return } + stop() + start() + } else { + stop() + } + } + + private func runOnMain(_ block: @escaping () -> Void) { + if Thread.isMainThread { + block() + } else { + DispatchQueue.main.async(execute: block) + } + } + + // MARK: - Lifecycle + + @objc func start() { + guard !running else { return } + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playback, mode: .default, options: [.mixWithOthers]) + try session.setActive(true) + } catch { + NSLog("KeepAliveManager: failed to activate audio session: %@", "\(error)") + return + } + + guard startEngine() else { + try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation]) + return + } + + addObservers() + running = true + NSLog("KeepAliveManager: started (continuous background sync ON)") + } + + @objc func stop() { + guard running || engine.isRunning else { return } + running = false + removeObservers() + player.stop() + engine.stop() + try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation]) + NSLog("KeepAliveManager: stopped (continuous background sync OFF)") + } + + // MARK: - Engine + + private func startEngine() -> Bool { + let format = engine.outputNode.outputFormat(forBus: 0) + guard format.sampleRate > 0, format.channelCount > 0 else { + NSLog("KeepAliveManager: invalid output format - cannot start engine") + return false + } + + if silentBuffer == nil || !(silentBuffer?.format.isEqual(format) ?? false) { + // ~0.5s of silence, looped. A small zero-filled buffer keeps CPU negligible. + let frames = AVAudioFrameCount(format.sampleRate * 0.5) + guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frames) else { + return false + } + buffer.frameLength = frames // freshly allocated PCM buffers are zero-filled == silence + silentBuffer = buffer + } + + if !engine.attachedNodes.contains(player) { + engine.attach(player) + } + engine.connect(player, to: engine.mainMixerNode, format: format) + engine.mainMixerNode.outputVolume = 0 + + engine.prepare() + do { + try engine.start() + } catch { + NSLog("KeepAliveManager: engine start failed: %@", "\(error)") + return false + } + + if let buffer = silentBuffer { + player.scheduleBuffer(buffer, at: nil, options: [.loops], completionHandler: nil) + } + player.play() + return true + } + + // MARK: - Interruptions + + private func addObservers() { + guard !observing else { return } + observing = true + let nc = NotificationCenter.default + nc.addObserver(self, selector: #selector(handleInterruption(_:)), + name: AVAudioSession.interruptionNotification, object: nil) + nc.addObserver(self, selector: #selector(handleMediaReset(_:)), + name: AVAudioSession.mediaServicesWereResetNotification, object: nil) + } + + private func removeObservers() { + guard observing else { return } + observing = false + NotificationCenter.default.removeObserver(self) + } + + /// A call/Siri/other app taking audio focus interrupts us. + @objc private func handleInterruption(_ note: Notification) { + guard let info = note.userInfo, + let raw = info[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: raw) else { return } + switch type { + case .began: + NSLog("KeepAliveManager: audio interrupted") + case .ended: + NSLog("KeepAliveManager: interruption ended - restarting") + runOnMain { [weak self] in self?.reconcile() } + @unknown default: + break + } + } + + /// Media services can reset (rare). Engine + buffers are invalid afterward, + /// so drop the buffer and rebuild from scratch. + @objc private func handleMediaReset(_ note: Notification) { + NSLog("KeepAliveManager: media services reset - rebuilding") + runOnMain { [weak self] in + self?.silentBuffer = nil + self?.reconcile() + } + } +} diff --git a/mobile-app/src/GoServerBridgeJSI.ts b/mobile-app/src/GoServerBridgeJSI.ts index 2307645..2dd73e7 100644 --- a/mobile-app/src/GoServerBridgeJSI.ts +++ b/mobile-app/src/GoServerBridgeJSI.ts @@ -19,6 +19,8 @@ export interface GoServerBridgeInterface { setWifiOnlySync(enabled: boolean): boolean; getChargingOnlySync(): boolean; setChargingOnlySync(enabled: boolean): boolean; + getContinuousBackgroundSync(): boolean; + setContinuousBackgroundSync(enabled: boolean): boolean; getAllowMeteredWifi(): boolean; setAllowMeteredWifi(enabled: boolean): boolean; getAllowMobileData(): boolean; @@ -128,6 +130,14 @@ class GoServerBridgeJSI implements GoServerBridgeInterface { return NativeGoServerBridge.setChargingOnlySync(enabled); } + getContinuousBackgroundSync(): boolean { + return NativeGoServerBridge.getContinuousBackgroundSync(); + } + + setContinuousBackgroundSync(enabled: boolean): boolean { + return NativeGoServerBridge.setContinuousBackgroundSync(enabled); + } + getAllowMeteredWifi(): boolean { return NativeGoServerBridge.getAllowMeteredWifi(); } diff --git a/mobile-app/src/NativeGoServerBridge.ts b/mobile-app/src/NativeGoServerBridge.ts index aad7432..04bdda2 100644 --- a/mobile-app/src/NativeGoServerBridge.ts +++ b/mobile-app/src/NativeGoServerBridge.ts @@ -20,6 +20,8 @@ export interface Spec extends TurboModule { readonly setWifiOnlySync: (enabled: boolean) => boolean; readonly getChargingOnlySync: () => boolean; readonly setChargingOnlySync: (enabled: boolean) => boolean; + readonly getContinuousBackgroundSync: () => boolean; + readonly setContinuousBackgroundSync: (enabled: boolean) => boolean; readonly getAllowMeteredWifi: () => boolean; readonly setAllowMeteredWifi: (enabled: boolean) => boolean; readonly getAllowMobileData: () => boolean; diff --git a/mobile-app/src/screens/SettingsScreen.tsx b/mobile-app/src/screens/SettingsScreen.tsx index 97712a4..aacb683 100644 --- a/mobile-app/src/screens/SettingsScreen.tsx +++ b/mobile-app/src/screens/SettingsScreen.tsx @@ -72,6 +72,7 @@ export function SettingsScreen() { const [batteryExempt, setBatteryExempt] = useState(false); const [externalControl, setExternalControl] = useState(false); const [startOnBoot, setStartOnBoot] = useState(false); + const [continuousBg, setContinuousBg] = useState(false); useEffect(() => { try { setWifiOnly(GoBridge.getWifiOnlySync()); @@ -80,6 +81,7 @@ export function SettingsScreen() { setAllowMobile(GoBridge.getAllowMobileData()); setExternalControl(GoBridge.getExternalControlEnabled()); setStartOnBoot(GoBridge.getStartOnBoot()); + setContinuousBg(GoBridge.getContinuousBackgroundSync()); } catch { // ignore - stays false } @@ -159,6 +161,32 @@ export function SettingsScreen() { writeExternalControl(false); }; + const writeContinuousBg = (next: boolean) => { + setContinuousBg(next); + try { + const persisted = GoBridge.setContinuousBackgroundSync(next); + setContinuousBg(persisted); + } catch (e) { + Alert.alert('Could not change setting', e instanceof Error ? e.message : String(e)); + setContinuousBg(!next); + } + }; + + const toggleContinuousBg = (value: boolean) => { + if (value) { + Alert.alert( + 'Keep syncing in the background?', + 'SyncUp will stay active so folders keep syncing even when the app is not open.\n\nThis uses noticeably more battery, because the sync engine and network stay awake continuously. Leave it off unless you need near-instant background sync and accept the battery cost.', + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Enable', onPress: () => writeContinuousBg(true) }, + ], + ); + return; + } + writeContinuousBg(false); + }; + const openBatterySettings = () => { try { const ok = GoBridge.openBatteryOptimizationSettings(); @@ -555,6 +583,25 @@ export function SettingsScreen() { )} + {Platform.OS === 'ios' && ( + + Background + + + Continuous background sync + + Off by default. When on, SyncUp keeps syncing while backgrounded instead of only in the brief windows iOS normally allows, closer to how it behaves on Android. Uses noticeably more battery, since the sync engine and network stay awake continuously. + + + + + + )} + {isAndroid && !batteryExempt && ( Power