1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-22 01:47:08 +02:00

feat(android) Check server is reachable before starting background backup (#8989)

* Check that server is reachable before starting backup work

* Fix iOS not starting background service

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
devjn 2024-04-23 20:50:34 +03:00 committed by GitHub
parent 661540c886
commit 0435de50f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 113 additions and 31 deletions

View File

@ -52,6 +52,7 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
.putBoolean(ContentObserverWorker.SHARED_PREF_SERVICE_ENABLED, true) .putBoolean(ContentObserverWorker.SHARED_PREF_SERVICE_ENABLED, true)
.putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long) .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_NOTIFICATION_TITLE, args.get(1) as String)
.putString(BackupWorker.SHARED_PREF_SERVER_URL, args.get(3) as String)
.apply() .apply()
ContentObserverWorker.enable(ctx, immediate = args.get(2) as Boolean) ContentObserverWorker.enable(ctx, immediate = args.get(2) as Boolean)
result.success(true) result.success(true)

View File

@ -11,8 +11,8 @@ import android.os.PowerManager
import android.os.SystemClock import android.os.SystemClock
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.concurrent.futures.CallbackToFutureAdapter
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.concurrent.futures.ResolvableFuture
import androidx.work.BackoffPolicy import androidx.work.BackoffPolicy
import androidx.work.Constraints import androidx.work.Constraints
import androidx.work.ForegroundInfo 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.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.view.FlutterCallbackInformation 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 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 { class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ctx, params), MethodChannel.MethodCallHandler {
private val resolvableFuture = ResolvableFuture.create<Result>()
private var engine: FlutterEngine? = null private var engine: FlutterEngine? = null
private lateinit var backgroundChannel: MethodChannel private lateinit var backgroundChannel: MethodChannel
private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager 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 notificationDetailBuilder: NotificationCompat.Builder? = null
private var fgFuture: ListenableFuture<Void>? = 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") Log.d(TAG, "startWork")
val ctx = applicationContext val ctx = applicationContext
val prefs = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
if (!flutterLoader.initialized()) { prefs.getString(SHARED_PREF_SERVER_URL, null)
flutterLoader.startInitialization(ctx) ?.takeIf { it.isNotEmpty() }
} ?.let { serverUrl -> doCoroutineWork(serverUrl) }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { ?: doWork()
// 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()
}
return resolvableFuture 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 * Starts the Dart runtime/engine and calls `_nativeEntry` function in
* `background.service.dart` to run the actual backup logic. * `background.service.dart` to run the actual backup logic.
@ -139,7 +193,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
engine = null engine = null
if (result != null) { if (result != null) {
Log.d(TAG, "stopEngine result=${result}") Log.d(TAG, "stopEngine result=${result}")
resolvableFuture.set(result) this.completer.set(result)
} }
waitOnSetForegroundAsync() waitOnSetForegroundAsync()
} }
@ -270,6 +324,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
const val SHARED_PREF_CALLBACK_KEY = "callbackDispatcherHandle" const val SHARED_PREF_CALLBACK_KEY = "callbackDispatcherHandle"
const val SHARED_PREF_NOTIFICATION_TITLE = "notificationTitle" const val SHARED_PREF_NOTIFICATION_TITLE = "notificationTitle"
const val SHARED_PREF_LAST_CHANGE = "lastChange" 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 TASK_NAME_BACKUP = "immich/BackupWorker"
private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService" 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" 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
}

View File

@ -171,9 +171,9 @@ class BackgroundServicePlugin: NSObject, FlutterPlugin {
return return
} }
// Requires 3 arguments in the array // Requires 3 or more arguments in the array
guard args.count == 3 else { guard args.count >= 3 else {
print("Requires 3 arguments and received \(args.count)") print("Requires 3 or more arguments and received \(args.count)")
result(FlutterMethodNotImplemented) result(FlutterMethodNotImplemented)
return return
} }

View File

@ -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/shared/services/api.service.dart';
import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/backup_progress.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
import 'package:path_provider_ios/path_provider_ios.dart'; import 'package:path_provider_ios/path_provider_ios.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
@ -68,8 +69,10 @@ class BackgroundService {
final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!; final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!;
final String title = final String title =
"backup_background_service_default_notification".tr(); "backup_background_service_default_notification".tr();
final bool ok = await _foregroundChannel final bool ok = await _foregroundChannel.invokeMethod(
.invokeMethod('enable', [callback.toRawHandle(), title, immediate]); 'enable',
[callback.toRawHandle(), title, immediate, getServerUrl()],
);
return ok; return ok;
} catch (error) { } catch (error) {
return false; return false;