From 33b1410d82d6aec718f0133b7001bd63142df9ec Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey Date: Thu, 18 Aug 2022 16:41:59 +0200 Subject: [PATCH] upload new photos in background with a service (#382) * properly done background backup service * new concurrency/locking management with heartbeat fix communication erros with Kotlin plugin on start/stop service methods better error handling for BackgroundService public methods Add default notification message when service is running * configurable WiFi & charging requirement for service * use translations in background service --- mobile/android/app/build.gradle | 5 +- .../com/example/immich_mobile/MainActivity.kt | 6 - .../example/mobile/BackgroundServicePlugin.kt | 98 +++++ .../kotlin/com/example/mobile/BackupWorker.kt | 333 +++++++++++++++ .../kotlin/com/example/mobile/MainActivity.kt | 7 + mobile/android/build.gradle | 3 + mobile/assets/i18n/en-US.json | 13 + mobile/lib/constants/locales.dart | 17 + mobile/lib/main.dart | 24 +- .../background.service.dart | 382 ++++++++++++++++++ .../background_service/localization.dart | 27 ++ .../backup/models/available_album.model.dart | 20 +- .../backup/models/backup_state.model.dart | 31 +- .../models/hive_backup_albums.model.dart | 30 +- .../models/hive_backup_albums.model.g.dart | 12 +- .../backup/providers/backup.provider.dart | 237 +++++++++-- .../backup/services/backup.service.dart | 147 ++++++- .../modules/backup/ui/album_info_card.dart | 6 +- .../views/backup_album_selection_page.dart | 2 +- .../backup/views/backup_controller_page.dart | 139 ++++++- mobile/pubspec.lock | 2 +- 21 files changed, 1462 insertions(+), 79 deletions(-) delete mode 100644 mobile/android/app/src/main/kotlin/com/example/immich_mobile/MainActivity.kt create mode 100644 mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt create mode 100644 mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt create mode 100644 mobile/lib/constants/locales.dart create mode 100644 mobile/lib/modules/backup/background_service/background.service.dart create mode 100644 mobile/lib/modules/backup/background_service/localization.dart diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 2f7ca021f..536166b01 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -80,5 +80,8 @@ flutter { } dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" + implementation "androidx.work:work-runtime-ktx:$work_version" + implementation "androidx.concurrent:concurrent-futures:$concurrent_version" + implementation "com.google.guava:guava:$guava_version" } diff --git a/mobile/android/app/src/main/kotlin/com/example/immich_mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/com/example/immich_mobile/MainActivity.kt deleted file mode 100644 index 520f053b5..000000000 --- a/mobile/android/app/src/main/kotlin/com/example/immich_mobile/MainActivity.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.example.immich_mobile - -import io.flutter.embedding.android.FlutterActivity - -class MainActivity: FlutterActivity() { -} 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 new file mode 100644 index 000000000..c3fb7a209 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt @@ -0,0 +1,98 @@ +package app.alextran.immich + +import android.content.Context +import android.net.Uri +import android.content.Intent +import android.provider.Settings +import android.util.Log +import android.widget.Toast +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +/** + * Android plugin for Dart `BackgroundService` + * + * Receives messages/method calls from the foreground Dart side to manage + * the background service, e.g. start (enqueue), stop (cancel) + */ +class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler { + + private var methodChannel: MethodChannel? = null + private var context: Context? = null + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + onAttachedToEngine(binding.applicationContext, binding.binaryMessenger) + } + + private fun onAttachedToEngine(ctx: Context, messenger: BinaryMessenger) { + context = ctx + methodChannel = MethodChannel(messenger, "immich/foregroundChannel") + methodChannel?.setMethodCallHandler(this) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + onDetachedFromEngine() + } + + private fun onDetachedFromEngine() { + methodChannel?.setMethodCallHandler(null) + methodChannel = null + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + val ctx = context!! + when(call.method) { + "initialize" -> { // needs to be called prior to any other method + val args = call.arguments>()!! + ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) + .edit().putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long).apply() + result.success(true) + } + "start" -> { + val args = call.arguments>()!! + val immediate = args.get(0) as Boolean + val keepExisting = args.get(1) as Boolean + val requireUnmeteredNetwork = args.get(2) as Boolean + val requireCharging = args.get(3) as Boolean + val notificationTitle = args.get(4) as String + ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) + .edit().putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, notificationTitle).apply() + BackupWorker.startWork(ctx, immediate, keepExisting, requireUnmeteredNetwork, requireCharging) + result.success(true) + } + "stop" -> { + BackupWorker.stopWork(ctx) + result.success(true) + } + "isEnabled" -> { + result.success(BackupWorker.isEnabled(ctx)) + } + "disableBatteryOptimizations" -> { + if(!BackupWorker.isIgnoringBatteryOptimizations(ctx)) { + val args = call.arguments>()!! + val text = args.get(0) as String + Toast.makeText(ctx, text, Toast.LENGTH_LONG).show() + val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.setData(Uri.parse("package:" + ctx.getPackageName())) + try { + ctx.startActivity(intent) + } catch(e: Exception) { + intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + try { + ctx.startActivity(intent) + } catch (e2: Exception) { + return result.success(false) + } + } + } + result.success(true) + } + else -> result.notImplemented() + } + } +} + +private const val TAG = "BackgroundServicePlugin" \ No newline at end of file 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 new file mode 100644 index 000000000..fb47da6a1 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt @@ -0,0 +1,333 @@ +package app.alextran.immich + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.PowerManager +import android.os.SystemClock +import android.provider.MediaStore +import android.provider.BaseColumns +import android.provider.MediaStore.MediaColumns +import android.provider.MediaStore.Images.Media +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import androidx.concurrent.futures.ResolvableFuture +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.Data +import androidx.work.ForegroundInfo +import androidx.work.ListenableWorker +import androidx.work.NetworkType +import androidx.work.WorkerParameters +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import com.google.common.util.concurrent.ListenableFuture +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.dart.DartExecutor +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 java.util.concurrent.TimeUnit + +/** + * Worker executed by Android WorkManager to perform backup in background + * + * Starts the Dart runtime/engine and calls `_nativeEntry` function in + * `background.service.dart` to run the actual backup logic. + * Called by Android WorkManager when all constraints for the work are met, + * i.e. a new photo/video is created on the device AND battery is not low. + * Optionally, unmetered network (wifi) and charging can be required. + * As this work is not triggered periodically, but on content change, the + * worker enqueues itself again with the same settings. + * In case the worker is stopped by the system (e.g. constraints like wifi + * are no longer met, or the system needs memory resources for more other + * more important work), the worker is replaced without the constraint on + * changed contents to run again as soon as deemed possible by the system. + */ +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 + private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext) + + override fun startWork(): ListenableFuture { + + val ctx = applicationContext + // enqueue itself once again to continue to listen on added photos/videos + enqueueMoreWork(ctx, + requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true), + requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false)) + + 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)!! + setForegroundAsync(createForegroundInfo(title)) + } + engine = FlutterEngine(ctx) + + flutterLoader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) { + runDart() + } + + return resolvableFuture + } + + /** + * Starts the Dart runtime/engine and calls `_nativeEntry` function in + * `background.service.dart` to run the actual backup logic. + */ + private fun runDart() { + val callbackDispatcherHandle = applicationContext.getSharedPreferences( + SHARED_PREF_NAME, Context.MODE_PRIVATE).getLong(SHARED_PREF_CALLBACK_KEY, 0L) + val callbackInformation = FlutterCallbackInformation.lookupCallbackInformation(callbackDispatcherHandle) + val appBundlePath = flutterLoader.findAppBundlePath() + + engine?.let { engine -> + backgroundChannel = MethodChannel(engine.dartExecutor, "immich/backgroundChannel") + backgroundChannel.setMethodCallHandler(this@BackupWorker) + engine.dartExecutor.executeDartCallback( + DartExecutor.DartCallback( + applicationContext.assets, + appBundlePath, + callbackInformation + ) + ) + } + } + + override fun onStopped() { + // called when the system has to stop this worker because constraints are + // no longer met or the system needs resources for more important tasks + Handler(Looper.getMainLooper()).postAtFrontOfQueue { + backgroundChannel.invokeMethod("systemStop", null) + } + // cannot await/get(block) on resolvableFuture as its already cancelled (would throw CancellationException) + // instead, wait for 5 seconds until forcefully stopping backup work + Handler(Looper.getMainLooper()).postDelayed({ + stopEngine(null) + }, 5000) + } + + + private fun stopEngine(result: Result?) { + if (result != null) { + resolvableFuture.set(result) + } else if (engine != null && inputData.getInt(DATA_KEY_RETRIES, 0) == 0) { + // stopped by system and this is the first time (content change constraints active) + // replace the task without the content constraints to finish the backup as soon as possible + enqueueMoreWork(applicationContext, + immediate = true, + requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true), + requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false), + retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1) + } + engine?.destroy() + engine = null + } + + override fun onMethodCall(call: MethodCall, r: MethodChannel.Result) { + when (call.method) { + "initialized" -> + backgroundChannel.invokeMethod( + "onAssetsChanged", + null, + object : MethodChannel.Result { + override fun notImplemented() { + stopEngine(Result.failure()) + } + + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + stopEngine(Result.failure()) + } + + override fun success(receivedResult: Any?) { + val success = receivedResult as Boolean + stopEngine(if(success) Result.success() else Result.retry()) + if (!success && inputData.getInt(DATA_KEY_RETRIES, 0) == 0) { + // there was an error (e.g. server not available) + // replace the task without the content constraints to finish the backup as soon as possible + enqueueMoreWork(applicationContext, + immediate = true, + requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true), + requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false), + retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1) + } + } + } + ) + "updateNotification" -> { + val args = call.arguments>()!! + val title = args.get(0) as String + val content = args.get(1) as String + if (isIgnoringBatteryOptimizations) { + setForegroundAsync(createForegroundInfo(title, content)) + } + } + "showError" -> { + val args = call.arguments>()!! + val title = args.get(0) as String + val content = args.get(1) as String + showError(title, content) + } + else -> r.notImplemented() + } + } + + private fun showError(title: String, content: String) { + val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ERROR_ID) + .setContentTitle(title) + .setTicker(title) + .setContentText(content) + .setSmallIcon(R.mipmap.ic_launcher) + .setAutoCancel(true) + .build() + val notificationId = SystemClock.uptimeMillis() as Int + notificationManager.notify(notificationId, notification) + } + + private fun createForegroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null): ForegroundInfo { + val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID) + .setContentTitle(title) + .setTicker(title) + .setContentText(content) + .setSmallIcon(R.mipmap.ic_launcher) + .setOngoing(true) + .build() + return ForegroundInfo(1, notification) + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun createChannel() { + val foreground = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, NotificationManager.IMPORTANCE_LOW) + notificationManager.createNotificationChannel(foreground) + val error = NotificationChannel(NOTIFICATION_CHANNEL_ERROR_ID, NOTIFICATION_CHANNEL_ERROR_ID, NotificationManager.IMPORTANCE_HIGH) + notificationManager.createNotificationChannel(error) + } + + companion object { + const val SHARED_PREF_NAME = "immichBackgroundService" + const val SHARED_PREF_CALLBACK_KEY = "callbackDispatcherHandle" + const val SHARED_PREF_SERVICE_ENABLED = "serviceEnabled" + const val SHARED_PREF_NOTIFICATION_TITLE = "notificationTitle" + + private const val TASK_NAME = "immich/photoListener" + private const val DATA_KEY_UNMETERED = "unmetered" + private const val DATA_KEY_CHARGING = "charging" + private const val DATA_KEY_RETRIES = "retries" + private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService" + private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError" + private const val NOTIFICATION_DEFAULT_TITLE = "Immich" + + /** + * Enqueues the `BackupWorker` to run when all constraints are met. + * + * @param context Android Context + * @param immediate whether to enqueue(replace) the worker without the content change constraint + * @param keepExisting if true, use `ExistingWorkPolicy.KEEP`, else `ExistingWorkPolicy.APPEND_OR_REPLACE` + * @param requireUnmeteredNetwork if true, task only runs if connected to wifi + * @param requireCharging if true, task only runs if device is charging + * @param retries retry count (should be 0 unless an error occured and this is a retry) + */ + fun startWork(context: Context, + immediate: Boolean = false, + keepExisting: Boolean = false, + requireUnmeteredNetwork: Boolean = false, + requireCharging: Boolean = false) { + context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) + .edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, true).apply() + enqueueMoreWork(context, immediate, keepExisting, requireUnmeteredNetwork, requireCharging) + } + + private fun enqueueMoreWork(context: Context, + immediate: Boolean = false, + keepExisting: Boolean = false, + requireUnmeteredNetwork: Boolean = false, + requireCharging: Boolean = false, + retries: Int = 0) { + if (!isEnabled(context)) { + return + } + val constraints = Constraints.Builder() + .setRequiredNetworkType(if (requireUnmeteredNetwork) NetworkType.UNMETERED else NetworkType.CONNECTED) + .setRequiresBatteryNotLow(true) + .setRequiresCharging(requireCharging); + if (!immediate) { + constraints + .addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true) + .addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true) + .addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true) + .addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true) + } + + val inputData = Data.Builder() + .putBoolean(DATA_KEY_CHARGING, requireCharging) + .putBoolean(DATA_KEY_UNMETERED, requireUnmeteredNetwork) + .putInt(DATA_KEY_RETRIES, retries) + .build() + + val photoCheck = OneTimeWorkRequest.Builder(BackupWorker::class.java) + .setConstraints(constraints.build()) + .setInputData(inputData) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + OneTimeWorkRequest.MIN_BACKOFF_MILLIS, + TimeUnit.MILLISECONDS) + .build() + val policy = if (immediate) ExistingWorkPolicy.REPLACE else (if (keepExisting) ExistingWorkPolicy.KEEP else ExistingWorkPolicy.APPEND_OR_REPLACE) + val op = WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME, policy, photoCheck) + val result = op.getResult().get() + } + + /** + * Stops the currently running worker (if any) and removes it from the work queue + */ + fun stopWork(context: Context) { + context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) + .edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, false).apply() + WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME) + } + + /** + * Returns `true` if the app is ignoring battery optimizations + */ + fun isIgnoringBatteryOptimizations(ctx: Context): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val pwrm = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager + val name = ctx.packageName + return pwrm.isIgnoringBatteryOptimizations(name) + } + return true + } + + /** + * Return true if the user has enabled the background backup service + */ + fun isEnabled(ctx: Context): Boolean { + return ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) + .getBoolean(SHARED_PREF_SERVICE_ENABLED, false) + } + + private val flutterLoader = FlutterLoader() + } +} + +private const val TAG = "BackupWorker" \ No newline at end of file diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/com/example/mobile/MainActivity.kt index 73e9173ea..77eee636e 100644 --- a/mobile/android/app/src/main/kotlin/com/example/mobile/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/com/example/mobile/MainActivity.kt @@ -1,6 +1,13 @@ package app.alextran.immich import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine class MainActivity: FlutterActivity() { + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + flutterEngine.getPlugins().add(BackgroundServicePlugin()) + } + } diff --git a/mobile/android/build.gradle b/mobile/android/build.gradle index 83ae22004..6847fe261 100644 --- a/mobile/android/build.gradle +++ b/mobile/android/build.gradle @@ -1,5 +1,8 @@ buildscript { ext.kotlin_version = '1.6.10' + ext.work_version = '2.7.1' + ext.concurrent_version = '1.1.0' + ext.guava_version = '31.0.1-android' repositories { google() mavenCentral() diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index ca4a07b54..2147ef041 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -16,10 +16,23 @@ "backup_album_selection_page_selection_info": "Selection Info", "backup_album_selection_page_total_assets": "Total unique assets", "backup_all": "All", + "backup_background_service_default_notification": "Checking for new assets…", + "backup_background_service_disable_battery_optimizations": "Please disable battery optimization for Immich to enable background backup", + "backup_background_service_upload_failure_notification": "Failed to upload {}", + "backup_background_service_in_progress_notification": "Backing up your assets…", + "backup_background_service_current_upload_notification": "Uploading {}", "backup_controller_page_albums": "Backup Albums", "backup_controller_page_backup": "Backup", "backup_controller_page_backup_selected": "Selected: ", "backup_controller_page_backup_sub": "Backed up photos and videos", + "backup_controller_page_background_description": "Turn on the background service to automatically backup any new assets without needing to open the app", + "backup_controller_page_background_wifi": "Only on WiFi", + "backup_controller_page_background_charging": "Only while charging", + "backup_controller_page_background_is_on": "Automatic background backup is on", + "backup_controller_page_background_is_off": "Automatic background backup is off", + "backup_controller_page_background_turn_on": "Turn on background service", + "backup_controller_page_background_turn_off": "Turn off background service", + "backup_controller_page_background_configure_error": "Failed to configure the background service", "backup_controller_page_cancel": "Cancel", "backup_controller_page_created": "Created on: {}", "backup_controller_page_desc_backup": "Turn on backup to automatically upload new assets to the server.", diff --git a/mobile/lib/constants/locales.dart b/mobile/lib/constants/locales.dart new file mode 100644 index 000000000..8aa642a35 --- /dev/null +++ b/mobile/lib/constants/locales.dart @@ -0,0 +1,17 @@ +import 'dart:ui'; + +const List locales = [ + // Default locale + Locale('en', 'US'), + // Additional locales + Locale('da', 'DK'), + Locale('de', 'DE'), + Locale('es', 'ES'), + Locale('fi', 'FI'), + Locale('fr', 'FR'), + Locale('it', 'IT'), + Locale('ja', 'JP'), + Locale('pl', 'PL') +]; + +const String translationsPath = 'assets/i18n'; diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index b41937523..ee5209b5c 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -7,6 +7,9 @@ import 'package:flutter/services.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/immich_colors.dart'; +import 'package:immich_mobile/constants/locales.dart'; +import 'package:immich_mobile/modules/backup/background_service/background.service.dart'; import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart'; import 'package:immich_mobile/modules/backup/providers/backup.provider.dart'; import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart'; @@ -43,20 +46,6 @@ void main() async { await EasyLocalization.ensureInitialized(); - var locales = const [ - // Default locale - Locale('en', 'US'), - // Additional locales - Locale('da', 'DK'), - Locale('de', 'DE'), - Locale('es', 'ES'), - Locale('fi', 'FI'), - Locale('fr', 'FR'), - Locale('it', 'IT'), - Locale('ja', 'JP'), - Locale('pl', 'PL') - ]; - if (kReleaseMode && Platform.isAndroid) { try { await FlutterDisplayMode.setHighRefreshRate(); @@ -68,7 +57,7 @@ void main() async { runApp( EasyLocalization( supportedLocales: locales, - path: 'assets/i18n', + path: translationsPath, useFallbackTranslations: true, fallbackLocale: locales.first, child: const ProviderScope(child: ImmichApp()), @@ -95,6 +84,7 @@ class ImmichAppState extends ConsumerState var isAuthenticated = ref.watch(authenticationProvider).isAuthenticated; if (isAuthenticated) { + ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); ref.watch(backupProvider.notifier).resumeBackup(); ref.watch(assetProvider.notifier).getAllAsset(); ref.watch(serverInfoProvider.notifier).getServerVersion(); @@ -134,6 +124,10 @@ class ImmichAppState extends ConsumerState initState() { super.initState(); initApp().then((_) => debugPrint("App Init Completed")); + WidgetsBinding.instance.addPostFrameCallback((_) { + // needs to be delayed so that EasyLocalization is working + ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); + }); } @override diff --git a/mobile/lib/modules/backup/background_service/background.service.dart b/mobile/lib/modules/backup/background_service/background.service.dart new file mode 100644 index 000000000..87acdbc40 --- /dev/null +++ b/mobile/lib/modules/backup/background_service/background.service.dart @@ -0,0 +1,382 @@ +import 'dart:async'; +import 'dart:developer'; +import 'dart:io'; +import 'dart:isolate'; +import 'dart:ui' show IsolateNameServer, PluginUtilities; +import 'package:cancellation_token_http/http.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/hive_box.dart'; +import 'package:immich_mobile/modules/backup/background_service/localization.dart'; +import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart'; +import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart'; +import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart'; +import 'package:immich_mobile/modules/backup/services/backup.service.dart'; +import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart'; +import 'package:immich_mobile/shared/services/api.service.dart'; +import 'package:photo_manager/photo_manager.dart'; + +final backgroundServiceProvider = Provider( + (ref) => BackgroundService(), +); + +/// Background backup service +class BackgroundService { + static const String _portNameLock = "immichLock"; + BackgroundService(); + static const MethodChannel _foregroundChannel = + MethodChannel('immich/foregroundChannel'); + static const MethodChannel _backgroundChannel = + MethodChannel('immich/backgroundChannel'); + bool _isForegroundInitialized = false; + bool _isBackgroundInitialized = false; + CancellationToken? _cancellationToken; + bool _canceledBySystem = false; + int _wantsLockTime = 0; + bool _hasLock = false; + SendPort? _waitingIsolate; + ReceivePort? _rp; + + bool get isForegroundInitialized { + return _isForegroundInitialized; + } + + bool get isBackgroundInitialized { + return _isBackgroundInitialized; + } + + Future _initialize() async { + final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!; + var result = await _foregroundChannel + .invokeMethod('initialize', [callback.toRawHandle()]); + _isForegroundInitialized = true; + return result; + } + + /// Ensures that the background service is enqueued if enabled in settings + Future resumeServiceIfEnabled() async { + return await isBackgroundBackupEnabled() && + await startService(keepExisting: true); + } + + /// Enqueues the background service + Future startService({ + bool immediate = false, + bool keepExisting = false, + bool requireUnmetered = true, + bool requireCharging = false, + }) async { + if (!Platform.isAndroid) { + return true; + } + try { + if (!_isForegroundInitialized) { + await _initialize(); + } + final String title = + "backup_background_service_default_notification".tr(); + final bool ok = await _foregroundChannel.invokeMethod( + 'start', + [immediate, keepExisting, requireUnmetered, requireCharging, title], + ); + return ok; + } catch (error) { + return false; + } + } + + /// Cancels the background service (if currently running) and removes it from work queue + Future stopService() async { + if (!Platform.isAndroid) { + return true; + } + try { + if (!_isForegroundInitialized) { + await _initialize(); + } + final ok = await _foregroundChannel.invokeMethod('stop'); + return ok; + } catch (error) { + return false; + } + } + + /// Returns `true` if the background service is enabled + Future isBackgroundBackupEnabled() async { + if (!Platform.isAndroid) { + return false; + } + try { + if (!_isForegroundInitialized) { + await _initialize(); + } + return await _foregroundChannel.invokeMethod("isEnabled"); + } catch (error) { + return false; + } + } + + /// Opens an activity to let the user disable battery optimizations for Immich + Future disableBatteryOptimizations() async { + if (!Platform.isAndroid) { + return true; + } + try { + if (!_isForegroundInitialized) { + await _initialize(); + } + final String message = + "backup_background_service_disable_battery_optimizations".tr(); + return await _foregroundChannel.invokeMethod( + 'disableBatteryOptimizations', + message, + ); + } catch (error) { + return false; + } + } + + /// Updates the notification shown by the background service + Future updateNotification({ + String title = "Immich", + String? content, + }) async { + if (!Platform.isAndroid) { + return true; + } + try { + if (_isBackgroundInitialized) { + return await _backgroundChannel + .invokeMethod('updateNotification', [title, content]); + } + } catch (error) { + debugPrint("[updateNotification] failed to communicate with plugin"); + } + return Future.value(false); + } + + /// Shows a new priority notification + Future showErrorNotification( + String title, + String content, + ) async { + if (!Platform.isAndroid) { + return true; + } + try { + if (_isBackgroundInitialized) { + return await _backgroundChannel + .invokeMethod('showError', [title, content]); + } + } catch (error) { + debugPrint("[showErrorNotification] failed to communicate with plugin"); + } + return Future.value(false); + } + + /// await to ensure this thread (foreground or background) has exclusive access + Future acquireLock() async { + if (!Platform.isAndroid) { + return true; + } + final int lockTime = Timeline.now; + _wantsLockTime = lockTime; + final ReceivePort rp = ReceivePort(_portNameLock); + _rp = rp; + final SendPort sp = rp.sendPort; + + while (!IsolateNameServer.registerPortWithName(sp, _portNameLock)) { + try { + await _checkLockReleasedWithHeartbeat(lockTime); + } catch (error) { + return false; + } + if (_wantsLockTime != lockTime) { + return false; + } + } + _hasLock = true; + rp.listen(_heartbeatListener); + return true; + } + + Future _checkLockReleasedWithHeartbeat(final int lockTime) async { + SendPort? other = IsolateNameServer.lookupPortByName(_portNameLock); + if (other != null) { + final ReceivePort tempRp = ReceivePort(); + final SendPort tempSp = tempRp.sendPort; + final bs = tempRp.asBroadcastStream(); + while (_wantsLockTime == lockTime) { + other.send(tempSp); + final dynamic answer = await bs.first + .timeout(const Duration(seconds: 5), onTimeout: () => null); + if (_wantsLockTime != lockTime) { + break; + } + if (answer == null) { + // other isolate failed to answer, assuming it exited without releasing the lock + if (other == IsolateNameServer.lookupPortByName(_portNameLock)) { + IsolateNameServer.removePortNameMapping(_portNameLock); + } + break; + } else if (answer == true) { + // other isolate released the lock + break; + } else if (answer == false) { + // other isolate is still active + } + final dynamic isFinished = await bs.first + .timeout(const Duration(seconds: 5), onTimeout: () => false); + if (isFinished == true) { + break; + } + } + tempRp.close(); + } + } + + void _heartbeatListener(dynamic msg) { + if (msg is SendPort) { + _waitingIsolate = msg; + msg.send(false); + } + } + + /// releases the exclusive access lock + void releaseLock() { + if (!Platform.isAndroid) { + return; + } + _wantsLockTime = 0; + if (_hasLock) { + IsolateNameServer.removePortNameMapping(_portNameLock); + _waitingIsolate?.send(true); + _waitingIsolate = null; + _hasLock = false; + } + _rp?.close(); + _rp = null; + } + + void _setupBackgroundCallHandler() { + _backgroundChannel.setMethodCallHandler(_callHandler); + _isBackgroundInitialized = true; + _backgroundChannel.invokeMethod('initialized'); + } + + Future _callHandler(MethodCall call) async { + switch (call.method) { + case "onAssetsChanged": + final Future translationsLoaded = loadTranslations(); + try { + final bool hasAccess = await acquireLock(); + if (!hasAccess) { + debugPrint("[_callHandler] could acquire lock, exiting"); + return false; + } + await translationsLoaded; + return await _onAssetsChanged(); + } catch (error) { + debugPrint(error.toString()); + return false; + } finally { + await Hive.close(); + releaseLock(); + } + case "systemStop": + _canceledBySystem = true; + _cancellationToken?.cancel(); + return true; + default: + debugPrint("Unknown method ${call.method}"); + return false; + } + } + + Future _onAssetsChanged() async { + await Hive.initFlutter(); + + Hive.registerAdapter(HiveSavedLoginInfoAdapter()); + Hive.registerAdapter(HiveBackupAlbumsAdapter()); + await Hive.openBox(userInfoBox); + await Hive.openBox(hiveLoginInfoBox); + + ApiService apiService = ApiService(); + apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey)); + apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey)); + BackupService backupService = BackupService(apiService); + + final Box box = + await Hive.openBox(hiveBackupInfoBox); + final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey); + if (backupAlbumInfo == null) { + return true; + } + + await PhotoManager.setIgnorePermissionCheck(true); + + if (_canceledBySystem) { + return false; + } + + final List toUpload = + await backupService.getAssetsToBackup(backupAlbumInfo); + + if (_canceledBySystem) { + return false; + } + + if (toUpload.isEmpty) { + return true; + } + + _cancellationToken = CancellationToken(); + final bool ok = await backupService.backupAsset( + toUpload, + _cancellationToken!, + _onAssetUploaded, + _onProgress, + _onSetCurrentBackupAsset, + _onBackupError, + ); + if (ok) { + await box.put( + backupInfoKey, + backupAlbumInfo, + ); + } + return ok; + } + + void _onAssetUploaded(String deviceAssetId, String deviceId) { + debugPrint("Uploaded $deviceAssetId from $deviceId"); + } + + void _onProgress(int sent, int total) {} + + void _onBackupError(ErrorUploadAsset errorAssetInfo) { + showErrorNotification( + "backup_background_service_upload_failure_notification" + .tr(args: [errorAssetInfo.fileName]), + errorAssetInfo.errorMessage, + ); + } + + void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) { + updateNotification( + title: "backup_background_service_in_progress_notification".tr(), + content: "backup_background_service_current_upload_notification" + .tr(args: [currentUploadAsset.fileName]), + ); + } +} + +/// entry point called by Kotlin/Java code; needs to be a top-level function +void _nativeEntry() { + WidgetsFlutterBinding.ensureInitialized(); + BackgroundService backgroundService = BackgroundService(); + backgroundService._setupBackgroundCallHandler(); +} diff --git a/mobile/lib/modules/backup/background_service/localization.dart b/mobile/lib/modules/backup/background_service/localization.dart new file mode 100644 index 000000000..27a74d4cf --- /dev/null +++ b/mobile/lib/modules/backup/background_service/localization.dart @@ -0,0 +1,27 @@ +import 'package:flutter/foundation.dart'; +import 'package:easy_localization/src/asset_loader.dart'; +import 'package:easy_localization/src/easy_localization_controller.dart'; +import 'package:easy_localization/src/localization.dart'; +import 'package:immich_mobile/constants/locales.dart'; + +/// Workaround to manually load translations in another Isolate +Future loadTranslations() async { + await EasyLocalizationController.initEasyLocation(); + + final controller = EasyLocalizationController( + supportedLocales: locales, + useFallbackTranslations: true, + saveLocale: true, + assetLoader: const RootBundleAssetLoader(), + path: translationsPath, + useOnlyLangCode: false, + onLoadError: (e) => debugPrint(e.toString()), + fallbackLocale: locales.first, + ); + + await controller.loadTranslations(); + + return Localization.load(controller.locale, + translations: controller.translations, + fallbackTranslations: controller.fallbackTranslations); +} diff --git a/mobile/lib/modules/backup/models/available_album.model.dart b/mobile/lib/modules/backup/models/available_album.model.dart index b3a4ab3fb..2ddbe1c70 100644 --- a/mobile/lib/modules/backup/models/available_album.model.dart +++ b/mobile/lib/modules/backup/models/available_album.model.dart @@ -4,35 +4,45 @@ import 'package:photo_manager/photo_manager.dart'; class AvailableAlbum { final AssetPathEntity albumEntity; + final DateTime? lastBackup; final Uint8List? thumbnailData; AvailableAlbum({ required this.albumEntity, + this.lastBackup, this.thumbnailData, }); AvailableAlbum copyWith({ AssetPathEntity? albumEntity, + DateTime? lastBackup, Uint8List? thumbnailData, }) { return AvailableAlbum( albumEntity: albumEntity ?? this.albumEntity, + lastBackup: lastBackup ?? this.lastBackup, thumbnailData: thumbnailData ?? this.thumbnailData, ); } + String get name => albumEntity.name; + + int get assetCount => albumEntity.assetCount; + + String get id => albumEntity.id; + + bool get isAll => albumEntity.isAll; + @override String toString() => - 'AvailableAlbum(albumEntity: $albumEntity, thumbnailData: $thumbnailData)'; + 'AvailableAlbum(albumEntity: $albumEntity, lastBackup: $lastBackup, thumbnailData: $thumbnailData)'; @override bool operator ==(Object other) { if (identical(this, other)) return true; - return other is AvailableAlbum && - other.albumEntity == albumEntity && - other.thumbnailData == thumbnailData; + return other is AvailableAlbum && other.albumEntity == albumEntity; } @override - int get hashCode => albumEntity.hashCode ^ thumbnailData.hashCode; + int get hashCode => albumEntity.hashCode; } diff --git a/mobile/lib/modules/backup/models/backup_state.model.dart b/mobile/lib/modules/backup/models/backup_state.model.dart index 8f41b0200..d026be23f 100644 --- a/mobile/lib/modules/backup/models/backup_state.model.dart +++ b/mobile/lib/modules/backup/models/backup_state.model.dart @@ -6,7 +6,7 @@ import 'package:photo_manager/photo_manager.dart'; import 'package:immich_mobile/modules/backup/models/available_album.model.dart'; import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart'; -enum BackUpProgressEnum { idle, inProgress, done } +enum BackUpProgressEnum { idle, inProgress, inBackground, done } class BackUpState { // enum @@ -15,11 +15,14 @@ class BackUpState { final double progressInPercentage; final CancellationToken cancelToken; final ServerInfoResponseDto serverInfo; + final bool backgroundBackup; + final bool backupRequireWifi; + final bool backupRequireCharging; /// All available albums on the device final List availableAlbums; - final Set selectedBackupAlbums; - final Set excludedBackupAlbums; + final Set selectedBackupAlbums; + final Set excludedBackupAlbums; /// Assets that are not overlapping in selected backup albums and excluded backup albums final Set allUniqueAssets; @@ -36,6 +39,9 @@ class BackUpState { required this.progressInPercentage, required this.cancelToken, required this.serverInfo, + required this.backgroundBackup, + required this.backupRequireWifi, + required this.backupRequireCharging, required this.availableAlbums, required this.selectedBackupAlbums, required this.excludedBackupAlbums, @@ -50,9 +56,12 @@ class BackUpState { double? progressInPercentage, CancellationToken? cancelToken, ServerInfoResponseDto? serverInfo, + bool? backgroundBackup, + bool? backupRequireWifi, + bool? backupRequireCharging, List? availableAlbums, - Set? selectedBackupAlbums, - Set? excludedBackupAlbums, + Set? selectedBackupAlbums, + Set? excludedBackupAlbums, Set? allUniqueAssets, Set? selectedAlbumsBackupAssetsIds, CurrentUploadAsset? currentUploadAsset, @@ -63,6 +72,10 @@ class BackUpState { progressInPercentage: progressInPercentage ?? this.progressInPercentage, cancelToken: cancelToken ?? this.cancelToken, serverInfo: serverInfo ?? this.serverInfo, + backgroundBackup: backgroundBackup ?? this.backgroundBackup, + backupRequireWifi: backupRequireWifi ?? this.backupRequireWifi, + backupRequireCharging: + backupRequireCharging ?? this.backupRequireCharging, availableAlbums: availableAlbums ?? this.availableAlbums, selectedBackupAlbums: selectedBackupAlbums ?? this.selectedBackupAlbums, excludedBackupAlbums: excludedBackupAlbums ?? this.excludedBackupAlbums, @@ -75,7 +88,7 @@ class BackUpState { @override String toString() { - return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)'; + return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)'; } @override @@ -89,6 +102,9 @@ class BackUpState { other.progressInPercentage == progressInPercentage && other.cancelToken == cancelToken && other.serverInfo == serverInfo && + other.backgroundBackup == backgroundBackup && + other.backupRequireWifi == backupRequireWifi && + other.backupRequireCharging == backupRequireCharging && collectionEquals(other.availableAlbums, availableAlbums) && collectionEquals(other.selectedBackupAlbums, selectedBackupAlbums) && collectionEquals(other.excludedBackupAlbums, excludedBackupAlbums) && @@ -107,6 +123,9 @@ class BackUpState { progressInPercentage.hashCode ^ cancelToken.hashCode ^ serverInfo.hashCode ^ + backgroundBackup.hashCode ^ + backupRequireWifi.hashCode ^ + backupRequireCharging.hashCode ^ availableAlbums.hashCode ^ selectedBackupAlbums.hashCode ^ excludedBackupAlbums.hashCode ^ diff --git a/mobile/lib/modules/backup/models/hive_backup_albums.model.dart b/mobile/lib/modules/backup/models/hive_backup_albums.model.dart index 23f21331e..f4a7fe4a1 100644 --- a/mobile/lib/modules/backup/models/hive_backup_albums.model.dart +++ b/mobile/lib/modules/backup/models/hive_backup_albums.model.dart @@ -13,9 +13,17 @@ class HiveBackupAlbums { @HiveField(1) List excludedAlbumsIds; + @HiveField(2, defaultValue: []) + List lastSelectedBackupTime; + + @HiveField(3, defaultValue: []) + List lastExcludedBackupTime; + HiveBackupAlbums({ required this.selectedAlbumIds, required this.excludedAlbumsIds, + required this.lastSelectedBackupTime, + required this.lastExcludedBackupTime, }); @override @@ -25,10 +33,16 @@ class HiveBackupAlbums { HiveBackupAlbums copyWith({ List? selectedAlbumIds, List? excludedAlbumsIds, + List? lastSelectedBackupTime, + List? lastExcludedBackupTime, }) { return HiveBackupAlbums( selectedAlbumIds: selectedAlbumIds ?? this.selectedAlbumIds, excludedAlbumsIds: excludedAlbumsIds ?? this.excludedAlbumsIds, + lastSelectedBackupTime: + lastSelectedBackupTime ?? this.lastSelectedBackupTime, + lastExcludedBackupTime: + lastExcludedBackupTime ?? this.lastExcludedBackupTime, ); } @@ -37,6 +51,8 @@ class HiveBackupAlbums { result.addAll({'selectedAlbumIds': selectedAlbumIds}); result.addAll({'excludedAlbumsIds': excludedAlbumsIds}); + result.addAll({'lastSelectedBackupTime': lastSelectedBackupTime}); + result.addAll({'lastExcludedBackupTime': lastExcludedBackupTime}); return result; } @@ -45,6 +61,10 @@ class HiveBackupAlbums { return HiveBackupAlbums( selectedAlbumIds: List.from(map['selectedAlbumIds']), excludedAlbumsIds: List.from(map['excludedAlbumsIds']), + lastSelectedBackupTime: + List.from(map['lastSelectedBackupTime']), + lastExcludedBackupTime: + List.from(map['lastExcludedBackupTime']), ); } @@ -60,9 +80,15 @@ class HiveBackupAlbums { return other is HiveBackupAlbums && listEquals(other.selectedAlbumIds, selectedAlbumIds) && - listEquals(other.excludedAlbumsIds, excludedAlbumsIds); + listEquals(other.excludedAlbumsIds, excludedAlbumsIds) && + listEquals(other.lastSelectedBackupTime, lastSelectedBackupTime) && + listEquals(other.lastExcludedBackupTime, lastExcludedBackupTime); } @override - int get hashCode => selectedAlbumIds.hashCode ^ excludedAlbumsIds.hashCode; + int get hashCode => + selectedAlbumIds.hashCode ^ + excludedAlbumsIds.hashCode ^ + lastSelectedBackupTime.hashCode ^ + lastExcludedBackupTime.hashCode; } diff --git a/mobile/lib/modules/backup/models/hive_backup_albums.model.g.dart b/mobile/lib/modules/backup/models/hive_backup_albums.model.g.dart index ada7306db..8ddc98146 100644 --- a/mobile/lib/modules/backup/models/hive_backup_albums.model.g.dart +++ b/mobile/lib/modules/backup/models/hive_backup_albums.model.g.dart @@ -19,17 +19,25 @@ class HiveBackupAlbumsAdapter extends TypeAdapter { return HiveBackupAlbums( selectedAlbumIds: (fields[0] as List).cast(), excludedAlbumsIds: (fields[1] as List).cast(), + lastSelectedBackupTime: + fields[2] == null ? [] : (fields[2] as List).cast(), + lastExcludedBackupTime: + fields[3] == null ? [] : (fields[3] as List).cast(), ); } @override void write(BinaryWriter writer, HiveBackupAlbums obj) { writer - ..writeByte(2) + ..writeByte(4) ..writeByte(0) ..write(obj.selectedAlbumIds) ..writeByte(1) - ..write(obj.excludedAlbumsIds); + ..write(obj.excludedAlbumsIds) + ..writeByte(2) + ..write(obj.lastSelectedBackupTime) + ..writeByte(3) + ..write(obj.lastExcludedBackupTime); } @override diff --git a/mobile/lib/modules/backup/providers/backup.provider.dart b/mobile/lib/modules/backup/providers/backup.provider.dart index d6a71bf12..baf66ea41 100644 --- a/mobile/lib/modules/backup/providers/backup.provider.dart +++ b/mobile/lib/modules/backup/providers/backup.provider.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:cancellation_token_http/http.dart'; import 'package:flutter/foundation.dart'; import 'package:hive_flutter/hive_flutter.dart'; @@ -9,9 +11,11 @@ import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.d import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart'; import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart'; import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart'; +import 'package:immich_mobile/modules/backup/background_service/background.service.dart'; import 'package:immich_mobile/modules/backup/services/backup.service.dart'; import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; +import 'package:immich_mobile/shared/providers/app_state.provider.dart'; import 'package:immich_mobile/shared/services/server_info.service.dart'; import 'package:openapi/api.dart'; import 'package:photo_manager/photo_manager.dart'; @@ -21,6 +25,7 @@ class BackupNotifier extends StateNotifier { this._backupService, this._serverInfoService, this._authState, + this._backgroundService, this.ref, ) : super( BackUpState( @@ -28,6 +33,9 @@ class BackupNotifier extends StateNotifier { allAssetsInDatabase: const [], progressInPercentage: 0, cancelToken: CancellationToken(), + backgroundBackup: false, + backupRequireWifi: true, + backupRequireCharging: false, serverInfo: ServerInfoResponseDto( diskAvailable: "0", diskAvailableRaw: 0, @@ -56,6 +64,7 @@ class BackupNotifier extends StateNotifier { final BackupService _backupService; final ServerInfoService _serverInfoService; final AuthenticationState _authState; + final BackgroundService _backgroundService; final Ref ref; /// @@ -66,7 +75,7 @@ class BackupNotifier extends StateNotifier { /// We have method to include and exclude albums /// The total unique assets will be used for backing mechanism /// - void addAlbumForBackup(AssetPathEntity album) { + void addAlbumForBackup(AvailableAlbum album) { if (state.excludedBackupAlbums.contains(album)) { removeExcludedAlbumForBackup(album); } @@ -76,7 +85,7 @@ class BackupNotifier extends StateNotifier { _updateBackupAssetCount(); } - void addExcludedAlbumForBackup(AssetPathEntity album) { + void addExcludedAlbumForBackup(AvailableAlbum album) { if (state.selectedBackupAlbums.contains(album)) { removeAlbumForBackup(album); } @@ -85,8 +94,8 @@ class BackupNotifier extends StateNotifier { _updateBackupAssetCount(); } - void removeAlbumForBackup(AssetPathEntity album) { - Set currentSelectedAlbums = state.selectedBackupAlbums; + void removeAlbumForBackup(AvailableAlbum album) { + Set currentSelectedAlbums = state.selectedBackupAlbums; currentSelectedAlbums.removeWhere((a) => a == album); @@ -94,8 +103,8 @@ class BackupNotifier extends StateNotifier { _updateBackupAssetCount(); } - void removeExcludedAlbumForBackup(AssetPathEntity album) { - Set currentExcludedAlbums = state.excludedBackupAlbums; + void removeExcludedAlbumForBackup(AvailableAlbum album) { + Set currentExcludedAlbums = state.excludedBackupAlbums; currentExcludedAlbums.removeWhere((a) => a == album); @@ -103,6 +112,50 @@ class BackupNotifier extends StateNotifier { _updateBackupAssetCount(); } + void configureBackgroundBackup({ + bool? enabled, + bool? requireWifi, + bool? requireCharging, + required void Function(String msg) onError, + }) async { + assert(enabled != null || requireWifi != null || requireCharging != null); + if (Platform.isAndroid) { + final bool wasEnabled = state.backgroundBackup; + final bool wasWifi = state.backupRequireWifi; + final bool wasCharing = state.backupRequireCharging; + state = state.copyWith( + backgroundBackup: enabled, + backupRequireWifi: requireWifi, + backupRequireCharging: requireCharging, + ); + + if (state.backgroundBackup) { + if (!wasEnabled) { + await _backgroundService.disableBatteryOptimizations(); + } + final bool success = await _backgroundService.stopService() && + await _backgroundService.startService( + requireUnmetered: state.backupRequireWifi, + requireCharging: state.backupRequireCharging, + ); + if (!success) { + state = state.copyWith( + backgroundBackup: wasEnabled, + backupRequireWifi: wasWifi, + backupRequireCharging: wasCharing, + ); + onError("backup_controller_page_background_configure_error"); + } + } else { + final bool success = await _backgroundService.stopService(); + if (!success) { + state = state.copyWith(backgroundBackup: wasEnabled); + onError("backup_controller_page_background_configure_error"); + } + } + } + } + /// /// Get all album on the device /// Get all selected and excluded album from the user's persistent storage @@ -144,6 +197,8 @@ class BackupNotifier extends StateNotifier { defaultValue: HiveBackupAlbums( selectedAlbumIds: [], excludedAlbumsIds: [], + lastSelectedBackupTime: [], + lastExcludedBackupTime: [], ), ); @@ -173,6 +228,10 @@ class BackupNotifier extends StateNotifier { HiveBackupAlbums( selectedAlbumIds: [albumHasAllAssets.id], excludedAlbumsIds: [], + lastSelectedBackupTime: [ + DateTime.fromMillisecondsSinceEpoch(0, isUtc: true) + ], + lastExcludedBackupTime: [], ), ); @@ -181,19 +240,37 @@ class BackupNotifier extends StateNotifier { // Generate AssetPathEntity from id to add to local state try { - for (var selectedAlbumId in backupAlbumInfo!.selectedAlbumIds) { - var albumAsset = await AssetPathEntity.fromId(selectedAlbumId); - state = state.copyWith( - selectedBackupAlbums: {...state.selectedBackupAlbums, albumAsset}, + Set selectedAlbums = {}; + for (var i = 0; i < backupAlbumInfo!.selectedAlbumIds.length; i++) { + var albumAsset = + await AssetPathEntity.fromId(backupAlbumInfo.selectedAlbumIds[i]); + selectedAlbums.add( + AvailableAlbum( + albumEntity: albumAsset, + lastBackup: backupAlbumInfo.lastSelectedBackupTime.length > i + ? backupAlbumInfo.lastSelectedBackupTime[i] + : DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), + ), ); } - for (var excludedAlbumId in backupAlbumInfo.excludedAlbumsIds) { - var albumAsset = await AssetPathEntity.fromId(excludedAlbumId); - state = state.copyWith( - excludedBackupAlbums: {...state.excludedBackupAlbums, albumAsset}, + Set excludedAlbums = {}; + for (var i = 0; i < backupAlbumInfo.excludedAlbumsIds.length; i++) { + var albumAsset = + await AssetPathEntity.fromId(backupAlbumInfo.excludedAlbumsIds[i]); + excludedAlbums.add( + AvailableAlbum( + albumEntity: albumAsset, + lastBackup: backupAlbumInfo.lastExcludedBackupTime.length > i + ? backupAlbumInfo.lastExcludedBackupTime[i] + : DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), + ), ); } + state = state.copyWith( + selectedBackupAlbums: selectedAlbums, + excludedBackupAlbums: excludedAlbums, + ); } catch (e) { debugPrint("[ERROR] Failed to generate album from id $e"); } @@ -209,14 +286,14 @@ class BackupNotifier extends StateNotifier { Set assetsFromExcludedAlbums = {}; for (var album in state.selectedBackupAlbums) { - var assets = - await album.getAssetListRange(start: 0, end: album.assetCount); + var assets = await album.albumEntity + .getAssetListRange(start: 0, end: album.assetCount); assetsFromSelectedAlbums.addAll(assets); } for (var album in state.excludedBackupAlbums) { - var assets = - await album.getAssetListRange(start: 0, end: album.assetCount); + var assets = await album.albumEntity + .getAssetListRange(start: 0, end: album.assetCount); assetsFromExcludedAlbums.addAll(assets); } @@ -263,12 +340,16 @@ class BackupNotifier extends StateNotifier { /// and then update the UI according to those information /// Future getBackupInfo() async { - await Future.wait([ - _getBackupAlbumsInfo(), - _updateServerInfo(), - ]); + final bool isEnabled = await _backgroundService.isBackgroundBackupEnabled(); + state = state.copyWith(backgroundBackup: isEnabled); + if (state.backupProgress != BackUpProgressEnum.inBackground) { + await Future.wait([ + _getBackupAlbumsInfo(), + _updateServerInfo(), + ]); - await _updateBackupAssetCount(); + await _updateBackupAssetCount(); + } } /// @@ -276,6 +357,7 @@ class BackupNotifier extends StateNotifier { /// Hive database /// void _updatePersistentAlbumsSelection() { + final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); Box backupAlbumInfoBox = Hive.box(hiveBackupInfoBox); backupAlbumInfoBox.put( @@ -283,6 +365,12 @@ class BackupNotifier extends StateNotifier { HiveBackupAlbums( selectedAlbumIds: state.selectedBackupAlbums.map((e) => e.id).toList(), excludedAlbumsIds: state.excludedBackupAlbums.map((e) => e.id).toList(), + lastSelectedBackupTime: state.selectedBackupAlbums + .map((e) => e.lastBackup ?? epoch) + .toList(), + lastExcludedBackupTime: state.excludedBackupAlbums + .map((e) => e.lastBackup ?? epoch) + .toList(), ), ); } @@ -290,7 +378,8 @@ class BackupNotifier extends StateNotifier { /// /// Invoke backup process /// - void startBackupProcess() async { + Future startBackupProcess() async { + assert(state.backupProgress == BackUpProgressEnum.idle); state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress); await getBackupInfo(); @@ -318,7 +407,7 @@ class BackupNotifier extends StateNotifier { // Perform Backup state = state.copyWith(cancelToken: CancellationToken()); - _backupService.backupAsset( + await _backupService.backupAsset( assetsWillBeBackup, state.cancelToken, _onAssetUploaded, @@ -326,6 +415,7 @@ class BackupNotifier extends StateNotifier { _onSetCurrentBackupAsset, _onBackupError, ); + await _notifyBackgroundServiceCanRun(); } else { PhotoManager.openSetting(); } @@ -340,6 +430,9 @@ class BackupNotifier extends StateNotifier { } void cancelBackup() { + if (state.backupProgress != BackUpProgressEnum.inProgress) { + _notifyBackgroundServiceCanRun(); + } state.cancelToken.cancel(); state = state.copyWith( backupProgress: BackUpProgressEnum.idle, @@ -359,10 +452,21 @@ class BackupNotifier extends StateNotifier { if (state.allUniqueAssets.length - state.selectedAlbumsBackupAssetsIds.length == 0) { + final latestAssetBackup = + state.allUniqueAssets.map((e) => e.modifiedDateTime).reduce( + (v, e) => e.isAfter(v) ? e : v, + ); state = state.copyWith( + selectedBackupAlbums: state.selectedBackupAlbums + .map((e) => e.copyWith(lastBackup: latestAssetBackup)) + .toSet(), + excludedBackupAlbums: state.excludedBackupAlbums + .map((e) => e.copyWith(lastBackup: latestAssetBackup)) + .toSet(), backupProgress: BackUpProgressEnum.done, progressInPercentage: 0.0, ); + _updatePersistentAlbumsSelection(); } _updateServerInfo(); @@ -385,7 +489,7 @@ class BackupNotifier extends StateNotifier { } } - void resumeBackup() { + Future _resumeBackup() async { // Check if user is login var accessKey = Hive.box(userInfoBox).get(accessTokenKey); @@ -404,13 +508,91 @@ class BackupNotifier extends StateNotifier { return; } + if (state.backupProgress == BackUpProgressEnum.inBackground) { + debugPrint("[resumeBackup] Background backup is running - abort"); + return; + } + // Run backup debugPrint("[resumeBackup] Start back up"); - startBackupProcess(); + await startBackupProcess(); } return; } + + Future resumeBackup() async { + if (Platform.isAndroid) { + // assumes the background service is currently running + // if true, waits until it has stopped to update the app state from HiveDB + // before actually resuming backup by calling the internal `_resumeBackup` + final BackUpProgressEnum previous = state.backupProgress; + state = state.copyWith(backupProgress: BackUpProgressEnum.inBackground); + final bool hasLock = await _backgroundService.acquireLock(); + if (!hasLock) { + return; + } + Box box = + await Hive.openBox(hiveBackupInfoBox); + HiveBackupAlbums? albums = box.get(backupInfoKey); + Set selectedAlbums = state.selectedBackupAlbums; + Set excludedAlbums = state.excludedBackupAlbums; + if (albums != null) { + selectedAlbums = _updateAlbumsBackupTime( + selectedAlbums, + albums.selectedAlbumIds, + albums.lastSelectedBackupTime, + ); + excludedAlbums = _updateAlbumsBackupTime( + excludedAlbums, + albums.excludedAlbumsIds, + albums.lastExcludedBackupTime, + ); + } + state = state.copyWith( + backupProgress: previous, + selectedBackupAlbums: selectedAlbums, + excludedBackupAlbums: excludedAlbums, + ); + } + return _resumeBackup(); + } + + Set _updateAlbumsBackupTime( + Set albums, + List ids, + List times, + ) { + Set result = {}; + for (int i = 0; i < ids.length; i++) { + try { + AvailableAlbum a = albums.firstWhere((e) => e.id == ids[i]); + result.add(a.copyWith(lastBackup: times[i])); + } on StateError { + debugPrint("[_updateAlbumBackupTime] failed to find album in state"); + } + } + return result; + } + + Future _notifyBackgroundServiceCanRun() async { + const allowedStates = [ + AppStateEnum.inactive, + AppStateEnum.paused, + AppStateEnum.detached, + ]; + if (Platform.isAndroid && + allowedStates.contains(ref.read(appStateProvider.notifier).state)) { + try { + if (Hive.isBoxOpen(hiveBackupInfoBox)) { + await Hive.box(hiveBackupInfoBox).close(); + } + } catch (error) { + debugPrint("[_notifyBackgroundServiceCanRun] failed to close box"); + } + _backgroundService.releaseLock(); + } + } } final backupProvider = @@ -419,6 +601,7 @@ final backupProvider = ref.watch(backupServiceProvider), ref.watch(serverInfoServiceProvider), ref.watch(authenticationProvider), + ref.watch(backgroundServiceProvider), ref, ); }); diff --git a/mobile/lib/modules/backup/services/backup.service.dart b/mobile/lib/modules/backup/services/backup.service.dart index e54633bc2..d6d5745d1 100644 --- a/mobile/lib/modules/backup/services/backup.service.dart +++ b/mobile/lib/modules/backup/services/backup.service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -9,6 +10,7 @@ import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart'; import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart'; +import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart'; import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/utils/files_helper.dart'; import 'package:openapi/api.dart'; @@ -39,8 +41,141 @@ class BackupService { } } - backupAsset( - Set assetList, + /// Returns all assets to backup from the backup info taking into account the + /// time of the last successfull backup per album + Future> getAssetsToBackup( + HiveBackupAlbums backupAlbumInfo, + ) async { + final List candidates = + await _buildUploadCandidates(backupAlbumInfo); + + final List toUpload = candidates.isEmpty + ? [] + : await _removeAlreadyUploadedAssets(candidates); + return toUpload; + } + + Future> _buildUploadCandidates( + HiveBackupAlbums backupAlbums, + ) async { + final filter = FilterOptionGroup( + containsPathModified: true, + orders: [const OrderOption(type: OrderOptionType.updateDate)], + ); + final now = DateTime.now(); + final List selectedAlbums = + await _loadAlbumsWithTimeFilter( + backupAlbums.selectedAlbumIds, + backupAlbums.lastSelectedBackupTime, + filter, + now, + ); + if (selectedAlbums.every((e) => e == null)) { + return []; + } + final int allIdx = selectedAlbums.indexWhere((e) => e != null && e.isAll); + if (allIdx != -1) { + final List excludedAlbums = + await _loadAlbumsWithTimeFilter( + backupAlbums.excludedAlbumsIds, + backupAlbums.lastExcludedBackupTime, + filter, + now, + ); + final List toAdd = await _fetchAssetsAndUpdateLastBackup( + selectedAlbums.slice(allIdx, allIdx + 1), + backupAlbums.lastSelectedBackupTime.slice(allIdx, allIdx + 1), + now, + ); + final List toRemove = await _fetchAssetsAndUpdateLastBackup( + excludedAlbums, + backupAlbums.lastExcludedBackupTime, + now, + ); + return toAdd.toSet().difference(toRemove.toSet()).toList(); + } else { + return await _fetchAssetsAndUpdateLastBackup( + selectedAlbums, + backupAlbums.lastSelectedBackupTime, + now, + ); + } + } + + Future> _loadAlbumsWithTimeFilter( + List albumIds, + List lastBackups, + FilterOptionGroup filter, + DateTime now, + ) async { + List result = List.filled(albumIds.length, null); + for (int i = 0; i < albumIds.length; i++) { + try { + final AssetPathEntity album = + await AssetPathEntity.obtainPathFromProperties( + id: albumIds[i], + optionGroup: filter.copyWith( + updateTimeCond: DateTimeCond( + // subtract 2 seconds to prevent missing assets due to rounding issues + min: lastBackups[i].subtract(const Duration(seconds: 2)), + max: now, + ), + ), + maxDateTimeToNow: false, + ); + result[i] = album; + } on StateError { + // either there are no assets matching the filter criteria OR the album no longer exists + } + } + return result; + } + + Future> _fetchAssetsAndUpdateLastBackup( + List albums, + List lastBackup, + DateTime now, + ) async { + List result = []; + for (int i = 0; i < albums.length; i++) { + final AssetPathEntity? a = albums[i]; + if (a != null && a.lastModified?.isBefore(lastBackup[i]) != true) { + result.addAll(await a.getAssetListRange(start: 0, end: a.assetCount)); + lastBackup[i] = now; + } + } + return result; + } + + Future> _removeAlreadyUploadedAssets( + List candidates, + ) async { + final String deviceId = Hive.box(userInfoBox).get(deviceIdKey); + if (candidates.length < 10) { + final List duplicateResponse = + await Future.wait( + candidates.map( + (e) => _apiService.assetApi.checkDuplicateAsset( + CheckDuplicateAssetDto(deviceAssetId: e.id, deviceId: deviceId), + ), + ), + ); + return candidates + .whereIndexed((i, e) => duplicateResponse[i]?.isExist == false) + .toList(); + } else { + final List? allAssetsInDatabase = await getDeviceBackupAsset(); + + if (allAssetsInDatabase == null) { + return candidates; + } + final Set inDb = allAssetsInDatabase.toSet(); + return candidates.whereNot((e) => inDb.contains(e.id)).toList(); + } + } + + Future backupAsset( + Iterable assetList, http.CancellationToken cancelToken, Function(String, String) singleAssetDoneCb, Function(int, int) uploadProgressCb, @@ -50,6 +185,7 @@ class BackupService { String deviceId = Hive.box(userInfoBox).get(deviceIdKey); String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey); File? file; + bool anyErrors = false; for (var entity in assetList) { try { @@ -60,7 +196,8 @@ class BackupService { } if (file != null) { - String originalFileName = await entity.titleAsync; + String originalFileName = + entity.title != null ? entity.title! : await entity.titleAsync; String fileNameWithoutPath = originalFileName.toString().split(".")[0]; var fileExtension = p.extension(file.path); @@ -134,9 +271,10 @@ class BackupService { } } on http.CancelledException { debugPrint("Backup was cancelled by the user"); - return; + return false; } catch (e) { debugPrint("ERROR backupAsset: ${e.toString()}"); + anyErrors = true; continue; } finally { if (Platform.isIOS) { @@ -144,6 +282,7 @@ class BackupService { } } } + return !anyErrors; } String _getAssetType(AssetType assetType) { diff --git a/mobile/lib/modules/backup/ui/album_info_card.dart b/mobile/lib/modules/backup/ui/album_info_card.dart index 18a660dcf..271bf295b 100644 --- a/mobile/lib/modules/backup/ui/album_info_card.dart +++ b/mobile/lib/modules/backup/ui/album_info_card.dart @@ -6,14 +6,14 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/backup/models/available_album.model.dart'; import 'package:immich_mobile/modules/backup/providers/backup.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart'; -import 'package:photo_manager/photo_manager.dart'; class AlbumInfoCard extends HookConsumerWidget { final Uint8List? imageData; - final AssetPathEntity albumInfo; + final AvailableAlbum albumInfo; const AlbumInfoCard({Key? key, this.imageData, required this.albumInfo}) : super(key: key); @@ -223,7 +223,7 @@ class AlbumInfoCard extends HookConsumerWidget { IconButton( onPressed: () { AutoRouter.of(context).push( - AlbumPreviewRoute(album: albumInfo), + AlbumPreviewRoute(album: albumInfo.albumEntity), ); }, icon: Icon( diff --git a/mobile/lib/modules/backup/views/backup_album_selection_page.dart b/mobile/lib/modules/backup/views/backup_album_selection_page.dart index 271c70a4b..59893117e 100644 --- a/mobile/lib/modules/backup/views/backup_album_selection_page.dart +++ b/mobile/lib/modules/backup/views/backup_album_selection_page.dart @@ -48,7 +48,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { : const EdgeInsets.all(0), child: AlbumInfoCard( imageData: thumbnailData, - albumInfo: availableAlbums[index].albumEntity, + albumInfo: availableAlbums[index], ), ); }), diff --git a/mobile/lib/modules/backup/views/backup_controller_page.dart b/mobile/lib/modules/backup/views/backup_controller_page.dart index 32ecd8741..75ff14c67 100644 --- a/mobile/lib/modules/backup/views/backup_controller_page.dart +++ b/mobile/lib/modules/backup/views/backup_controller_page.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -20,9 +22,12 @@ class BackupControllerPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { BackUpState backupState = ref.watch(backupProvider); AuthenticationState authenticationState = ref.watch(authenticationProvider); + bool hasExclusiveAccess = + backupState.backupProgress != BackUpProgressEnum.inBackground; bool shouldBackup = backupState.allUniqueAssets.length - - backupState.selectedAlbumsBackupAssetsIds.length == - 0 + backupState.selectedAlbumsBackupAssetsIds.length == + 0 || + !hasExclusiveAccess ? false : true; @@ -141,6 +146,99 @@ class BackupControllerPage extends HookConsumerWidget { ); } + void _showErrorToUser(String msg) { + final snackBar = SnackBar( + content: Text( + msg.tr(), + ), + backgroundColor: Colors.red, + ); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + ListTile _buildBackgroundBackupController() { + final bool isBackgroundEnabled = backupState.backgroundBackup; + final bool isWifiRequired = backupState.backupRequireWifi; + final bool isChargingRequired = backupState.backupRequireCharging; + final Color activeColor = Theme.of(context).primaryColor; + return ListTile( + isThreeLine: true, + leading: isBackgroundEnabled + ? Icon( + Icons.cloud_sync_rounded, + color: activeColor, + ) + : const Icon(Icons.cloud_sync_rounded), + title: Text( + isBackgroundEnabled + ? "backup_controller_page_background_is_on" + : "backup_controller_page_background_is_off", + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ).tr(), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isBackgroundEnabled) + const Text("backup_controller_page_background_description").tr(), + if (isBackgroundEnabled) + SwitchListTile( + title: + const Text("backup_controller_page_background_wifi").tr(), + secondary: Icon( + Icons.wifi, + color: isWifiRequired ? activeColor : null, + ), + dense: true, + activeColor: activeColor, + value: isWifiRequired, + onChanged: hasExclusiveAccess + ? (isChecked) => ref + .read(backupProvider.notifier) + .configureBackgroundBackup( + requireWifi: isChecked, + onError: _showErrorToUser, + ) + : null, + ), + if (isBackgroundEnabled) + SwitchListTile( + title: const Text("backup_controller_page_background_charging") + .tr(), + secondary: Icon( + Icons.charging_station, + color: isChargingRequired ? activeColor : null, + ), + dense: true, + activeColor: activeColor, + value: isChargingRequired, + onChanged: hasExclusiveAccess + ? (isChecked) => ref + .read(backupProvider.notifier) + .configureBackgroundBackup( + requireCharging: isChecked, + onError: _showErrorToUser, + ) + : null, + ), + ElevatedButton( + onPressed: () => + ref.read(backupProvider.notifier).configureBackgroundBackup( + enabled: !isBackgroundEnabled, + onError: _showErrorToUser, + ), + child: Text( + isBackgroundEnabled + ? "backup_controller_page_background_turn_off" + : "backup_controller_page_background_turn_on", + style: + const TextStyle(fontWeight: FontWeight.bold, fontSize: 12), + ).tr(), + ), + ], + ), + ); + } + Widget _buildSelectedAlbumName() { var text = "backup_controller_page_backup_selected".tr(); var albums = ref.watch(backupProvider).selectedBackupAlbums; @@ -237,9 +335,12 @@ class BackupControllerPage extends HookConsumerWidget { ), ), trailing: ElevatedButton( - onPressed: () { - AutoRouter.of(context).push(const BackupAlbumSelectionRoute()); - }, + onPressed: hasExclusiveAccess + ? () { + AutoRouter.of(context) + .push(const BackupAlbumSelectionRoute()); + } + : null, child: const Text( "backup_controller_page_select", style: TextStyle( @@ -400,7 +501,10 @@ class BackupControllerPage extends HookConsumerWidget { void startBackup() { ref.watch(errorBackupListProvider.notifier).empty(); - ref.watch(backupProvider.notifier).startBackupProcess(); + if (ref.watch(backupProvider).backupProgress != + BackUpProgressEnum.inBackground) { + ref.watch(backupProvider.notifier).startBackupProcess(); + } } return Scaffold( @@ -433,6 +537,27 @@ class BackupControllerPage extends HookConsumerWidget { style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), ).tr(), ), + hasExclusiveAccess + ? const SizedBox.shrink() + : Card( + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(5), // if you need this + side: const BorderSide( + color: Colors.black12, + width: 1, + ), + ), + elevation: 0, + borderOnForeground: false, + child: const Padding( + padding: EdgeInsets.all(16.0), + child: Text( + "Background backup is currently running, some actions are disabled", + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), _buildFolderSelectionTile(), BackupInfoCard( title: "backup_controller_page_total".tr(), @@ -452,6 +577,8 @@ class BackupControllerPage extends HookConsumerWidget { ), const Divider(), _buildAutoBackupController(), + if (Platform.isAndroid) const Divider(), + if (Platform.isAndroid) _buildBackgroundBackupController(), const Divider(), _buildStorageInformation(), const Divider(), diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 5e5131f3d..9c1fb34da 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -35,7 +35,7 @@ packages: name: async url: "https://pub.dartlang.org" source: hosted - version: "2.8.2" + version: "2.9.0" auto_route: dependency: "direct main" description: