diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt b/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt index 6541ad5755264..1d23c5665c944 100644 --- a/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt +++ b/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt @@ -52,6 +52,7 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler { .putBoolean(ContentObserverWorker.SHARED_PREF_SERVICE_ENABLED, true) .putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long) .putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args.get(1) as String) + .putString(BackupWorker.SHARED_PREF_SERVER_URL, args.get(3) as String) .apply() ContentObserverWorker.enable(ctx, immediate = args.get(2) as Boolean) result.success(true) diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt b/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt index b6b78c2cba294..214fca983bb2e 100644 --- a/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt +++ b/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt @@ -11,8 +11,8 @@ import android.os.PowerManager import android.os.SystemClock import android.util.Log import androidx.annotation.RequiresApi +import androidx.concurrent.futures.CallbackToFutureAdapter import androidx.core.app.NotificationCompat -import androidx.concurrent.futures.ResolvableFuture import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.ForegroundInfo @@ -30,6 +30,16 @@ import io.flutter.embedding.engine.loader.FlutterLoader import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.view.FlutterCallbackInformation +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import java.io.IOException +import java.net.HttpURLConnection +import java.net.InetAddress +import java.net.URL import java.util.concurrent.TimeUnit /** @@ -42,7 +52,6 @@ import java.util.concurrent.TimeUnit */ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ctx, params), MethodChannel.MethodCallHandler { - private val resolvableFuture = ResolvableFuture.create() private var engine: FlutterEngine? = null private lateinit var backgroundChannel: MethodChannel private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -52,35 +61,80 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct private var notificationDetailBuilder: NotificationCompat.Builder? = null private var fgFuture: ListenableFuture? = null - override fun startWork(): ListenableFuture { + private val job = Job() + private lateinit var completer: CallbackToFutureAdapter.Completer + private val resolvableFuture = CallbackToFutureAdapter.getFuture { completer -> + this.completer = completer + null + } + + init { + resolvableFuture.addListener( + Runnable { + if (resolvableFuture.isCancelled) { + job.cancel() + } + }, + taskExecutor.serialTaskExecutor + ) + } + override fun startWork(): ListenableFuture { Log.d(TAG, "startWork") val ctx = applicationContext + val prefs = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) - if (!flutterLoader.initialized()) { - flutterLoader.startInitialization(ctx) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // Create a Notification channel if necessary - createChannel() - } - if (isIgnoringBatteryOptimizations) { - // normal background services can only up to 10 minutes - // foreground services are allowed to run indefinitely - // requires battery optimizations to be disabled (either manually by the user - // or by the system learning that immich is important to the user) - val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) - .getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!! - showInfo(getInfoBuilder(title, indeterminate=true).build()) - } - engine = FlutterEngine(ctx) + prefs.getString(SHARED_PREF_SERVER_URL, null) + ?.takeIf { it.isNotEmpty() } + ?.let { serverUrl -> doCoroutineWork(serverUrl) } + ?: doWork() + return resolvableFuture + } - flutterLoader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) { - runDart() + /** + * This function is used to check if server URL is reachable before starting the backup work. + * Check must be done in a background to avoid blocking the main thread. + */ + private fun doCoroutineWork(serverUrl : String) { + CoroutineScope(Dispatchers.Default + job).launch { + val isReachable = isUrlReachableHttp(serverUrl) + withContext(Dispatchers.Main) { + if (isReachable) { + doWork() + } else { + // Fail when the URL is not reachable + completer.set(Result.failure()) + } + } } + } - return resolvableFuture + private fun doWork() { + Log.d(TAG, "doWork") + val ctx = applicationContext + + if (!flutterLoader.initialized()) { + flutterLoader.startInitialization(ctx) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Create a Notification channel if necessary + createChannel() + } + if (isIgnoringBatteryOptimizations) { + // normal background services can only up to 10 minutes + // foreground services are allowed to run indefinitely + // requires battery optimizations to be disabled (either manually by the user + // or by the system learning that immich is important to the user) + val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) + .getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!! + showInfo(getInfoBuilder(title, indeterminate=true).build()) + } + engine = FlutterEngine(ctx) + + flutterLoader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) { + runDart() + } } /** @@ -139,7 +193,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct engine = null if (result != null) { Log.d(TAG, "stopEngine result=${result}") - resolvableFuture.set(result) + this.completer.set(result) } waitOnSetForegroundAsync() } @@ -270,6 +324,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct const val SHARED_PREF_CALLBACK_KEY = "callbackDispatcherHandle" const val SHARED_PREF_NOTIFICATION_TITLE = "notificationTitle" const val SHARED_PREF_LAST_CHANGE = "lastChange" + const val SHARED_PREF_SERVER_URL = "serverUrl" private const val TASK_NAME_BACKUP = "immich/BackupWorker" private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService" @@ -360,3 +415,26 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct } private const val TAG = "BackupWorker" + +/** + * Check if the given URL is reachable via HTTP + */ +suspend fun isUrlReachableHttp(url: String, timeoutMillis: Long = 5000L): Boolean { + return withTimeoutOrNull(timeoutMillis) { + var httpURLConnection: HttpURLConnection? = null + try { + httpURLConnection = (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = "HEAD" + connectTimeout = timeoutMillis.toInt() + readTimeout = timeoutMillis.toInt() + } + httpURLConnection.connect() + httpURLConnection.responseCode == HttpURLConnection.HTTP_OK + } catch (e: Exception) { + Log.e(TAG, "Failed to reach server URL: $e") + false + } finally { + httpURLConnection?.disconnect() + } + } == true +} diff --git a/mobile/ios/Runner/BackgroundSync/BackgroundServicePlugin.swift b/mobile/ios/Runner/BackgroundSync/BackgroundServicePlugin.swift index c84b037daf5c1..20626c6aa11bb 100644 --- a/mobile/ios/Runner/BackgroundSync/BackgroundServicePlugin.swift +++ b/mobile/ios/Runner/BackgroundSync/BackgroundServicePlugin.swift @@ -171,9 +171,9 @@ class BackgroundServicePlugin: NSObject, FlutterPlugin { return } - // Requires 3 arguments in the array - guard args.count == 3 else { - print("Requires 3 arguments and received \(args.count)") + // Requires 3 or more arguments in the array + guard args.count >= 3 else { + print("Requires 3 or more arguments and received \(args.count)") result(FlutterMethodNotImplemented) return } diff --git a/mobile/lib/modules/backup/background_service/background.service.dart b/mobile/lib/modules/backup/background_service/background.service.dart index cbee1211053e4..8358043894330 100644 --- a/mobile/lib/modules/backup/background_service/background.service.dart +++ b/mobile/lib/modules/backup/background_service/background.service.dart @@ -20,6 +20,7 @@ import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/diff.dart'; +import 'package:immich_mobile/utils/url_helper.dart'; import 'package:isar/isar.dart'; import 'package:path_provider_ios/path_provider_ios.dart'; import 'package:photo_manager/photo_manager.dart'; @@ -68,8 +69,10 @@ class BackgroundService { final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!; final String title = "backup_background_service_default_notification".tr(); - final bool ok = await _foregroundChannel - .invokeMethod('enable', [callback.toRawHandle(), title, immediate]); + final bool ok = await _foregroundChannel.invokeMethod( + 'enable', + [callback.toRawHandle(), title, immediate, getServerUrl()], + ); return ok; } catch (error) { return false;