diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 96d2db23f5..a6f86b8537 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -90,7 +90,7 @@ flutter { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version" - implementation "androidx.work:work-runtime-ktx:$work_version" + implementation "androidx.work:work-runtime:$work_version" implementation "androidx.concurrent:concurrent-futures:$concurrent_version" implementation "com.google.guava:guava:$guava_version" implementation "com.github.bumptech.glide:glide:$glide_version" 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 6541ad5755..1d23c5665c 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 660e1d55ba..dc7c4a9c37 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,37 +61,82 @@ 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) - - flutterLoader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) { - runDart() - } - + prefs.getString(SHARED_PREF_SERVER_URL, null) + ?.takeIf { it.isNotEmpty() } + ?.let { serverUrl -> doCoroutineWork(serverUrl) } + ?: doWork() return resolvableFuture } + /** + * 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()) + } + } + } + } + + 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() + } + } + /** * Starts the Dart runtime/engine and calls `_nativeEntry` function in * `background.service.dart` to run the actual backup logic. @@ -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,13 +324,14 @@ 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" private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError" private const val NOTIFICATION_DEFAULT_TITLE = "Immich" private const val NOTIFICATION_ID = 1 - private const val NOTIFICATION_ERROR_ID = 2 + private const val NOTIFICATION_ERROR_ID = 2 private const val NOTIFICATION_DETAIL_ID = 3 private const val ONE_MINUTE = 60000L @@ -304,7 +359,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct val workInfoList = workInfoFuture.get(1000, TimeUnit.MILLISECONDS) if (workInfoList != null) { for (workInfo in workInfoList) { - if (workInfo.getState() == WorkInfo.State.ENQUEUED) { + if (workInfo.state == WorkInfo.State.ENQUEUED) { val workRequest = buildWorkRequest(requireWifi, requireCharging) wm.enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.REPLACE, workRequest) Log.d(TAG, "updateBackupWorker updated BackupWorker constraints") @@ -359,4 +414,27 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct } } -private const val TAG = "BackupWorker" \ No newline at end of file +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/android/build.gradle b/mobile/android/build.gradle index 4dacde5a9d..e9c271b2c5 100644 --- a/mobile/android/build.gradle +++ b/mobile/android/build.gradle @@ -1,7 +1,7 @@ buildscript { - ext.kotlin_version = '1.8.20' + ext.kotlin_version = '1.8.22' ext.kotlin_coroutines_version = '1.7.1' - ext.work_version = '2.7.1' + ext.work_version = '2.9.0' ext.concurrent_version = '1.1.0' ext.guava_version = '33.0.0-android' ext.glide_version = '4.14.2' diff --git a/mobile/lib/modules/backup/background_service/background.service.dart b/mobile/lib/modules/backup/background_service/background.service.dart index cbee121105..8358043894 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;