mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 11:19:10 -05:00 
			
		
		
		
	feat(android) Check server is reachable before starting background backup (#8594)
* Bump androidx work version to 2.9.0 * Check that server is reachable before starting backup work * Dart format * Cleanup debug logs * Fix analysis
This commit is contained in:
		
							parent
							
								
									3abfe3c99e
								
							
						
					
					
						commit
						71b6d8b569
					
				@ -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"
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
 | 
			
		||||
@ -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<Result>()
 | 
			
		||||
    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<Void>? = null
 | 
			
		||||
 | 
			
		||||
    override fun startWork(): ListenableFuture<ListenableWorker.Result> {
 | 
			
		||||
    private val job = Job()
 | 
			
		||||
    private lateinit var completer: CallbackToFutureAdapter.Completer<Result>
 | 
			
		||||
    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<ListenableWorker.Result> {
 | 
			
		||||
        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"
 | 
			
		||||
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
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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'
 | 
			
		||||
 | 
			
		||||
@ -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;
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user