Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -64,13 +65,24 @@ 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,
KEY_ALLOW_METERED_WIFI,
KEY_ALLOW_MOBILE_DATA,
KEY_EXTERNAL_CONTROL,
KEY_START_ON_BOOT,
KEY_CONTINUOUS_BG_SYNC,
)

fun exportAsJson(context: Context): String {
Expand Down
4 changes: 4 additions & 0 deletions mobile-app/ios/syncup.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -45,6 +46,7 @@
FB0085F001083A8C91ADF71C /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = syncup/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
G0S1234567890ABC12345679 /* GoServerBridge.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; name = GoServerBridge.mm; path = syncup/GoServerBridge.mm; sourceTree = "<group>"; };
G0S1234567890ABC12345680 /* GoServerBridge.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = GoServerBridge.h; path = syncup/GoServerBridge.h; sourceTree = "<group>"; };
KA01234567890ABC12345001 /* KeepAliveManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = KeepAliveManager.swift; path = syncup/KeepAliveManager.swift; sourceTree = "<group>"; };
QL01234567890ABC12345001 /* QuickLookPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = QuickLookPresenter.swift; path = syncup/QuickLookPresenter.swift; sourceTree = "<group>"; };
SF01234567890ABC12345001 /* ScopedFolderStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScopedFolderStore.swift; path = syncup/ScopedFolderStore.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
Expand All @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
6 changes: 6 additions & 0 deletions mobile-app/ios/syncup/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
}
Expand Down
63 changes: 59 additions & 4 deletions mobile-app/ios/syncup/BackgroundManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions mobile-app/ios/syncup/GoBridgeWrapper.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
14 changes: 14 additions & 0 deletions mobile-app/ios/syncup/GoBridgeWrapper.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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; }
Expand Down
2 changes: 2 additions & 0 deletions mobile-app/ios/syncup/GoServerBridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ class GoServerBridgeImpl : public facebook::react::NativeGoServerBridgeCxxSpec<G
bool setWifiOnlySync(facebook::jsi::Runtime &rt, bool enabled);
bool getChargingOnlySync(facebook::jsi::Runtime &rt);
bool setChargingOnlySync(facebook::jsi::Runtime &rt, bool enabled);
bool getContinuousBackgroundSync(facebook::jsi::Runtime &rt);
bool setContinuousBackgroundSync(facebook::jsi::Runtime &rt, bool enabled);
bool getAllowMeteredWifi(facebook::jsi::Runtime &rt);
bool setAllowMeteredWifi(facebook::jsi::Runtime &rt, bool enabled);
bool getAllowMobileData(facebook::jsi::Runtime &rt);
Expand Down
8 changes: 8 additions & 0 deletions mobile-app/ios/syncup/GoServerBridge.mm
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@
return [GoBridgeWrapper setChargingOnlySync:(enabled ? YES : NO)];
}

bool GoServerBridgeImpl::getContinuousBackgroundSync(facebook::jsi::Runtime &rt) {
return [GoBridgeWrapper getContinuousBackgroundSync];
}

bool GoServerBridgeImpl::setContinuousBackgroundSync(facebook::jsi::Runtime &rt, bool enabled) {
return [GoBridgeWrapper setContinuousBackgroundSync:(enabled ? YES : NO)];
}

bool GoServerBridgeImpl::getAllowMeteredWifi(facebook::jsi::Runtime &rt) {
return [[NSUserDefaults standardUserDefaults] boolForKey:@"syncthing.allowMeteredWifi"];
}
Expand Down
2 changes: 2 additions & 0 deletions mobile-app/ios/syncup/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
<string>audio</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
Expand Down
Loading
Loading