mirror of
https://github.com/immich-app/immich.git
synced 2024-12-26 10:50:29 +02:00
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
This commit is contained in:
parent
f35ebec7c6
commit
33b1410d82
@ -80,5 +80,8 @@ flutter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
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"
|
||||||
}
|
}
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
package com.example.immich_mobile
|
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
|
||||||
|
|
||||||
class MainActivity: FlutterActivity() {
|
|
||||||
}
|
|
@ -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<ArrayList<*>>()!!
|
||||||
|
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<ArrayList<*>>()!!
|
||||||
|
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<ArrayList<*>>()!!
|
||||||
|
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"
|
@ -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<Result>()
|
||||||
|
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<ListenableWorker.Result> {
|
||||||
|
|
||||||
|
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<ArrayList<*>>()!!
|
||||||
|
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<ArrayList<*>>()!!
|
||||||
|
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"
|
@ -1,6 +1,13 @@
|
|||||||
package app.alextran.immich
|
package app.alextran.immich
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
|
||||||
class MainActivity: FlutterActivity() {
|
class MainActivity: FlutterActivity() {
|
||||||
|
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
flutterEngine.getPlugins().add(BackgroundServicePlugin())
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.6.10'
|
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 {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
@ -16,10 +16,23 @@
|
|||||||
"backup_album_selection_page_selection_info": "Selection Info",
|
"backup_album_selection_page_selection_info": "Selection Info",
|
||||||
"backup_album_selection_page_total_assets": "Total unique assets",
|
"backup_album_selection_page_total_assets": "Total unique assets",
|
||||||
"backup_all": "All",
|
"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_albums": "Backup Albums",
|
||||||
"backup_controller_page_backup": "Backup",
|
"backup_controller_page_backup": "Backup",
|
||||||
"backup_controller_page_backup_selected": "Selected: ",
|
"backup_controller_page_backup_selected": "Selected: ",
|
||||||
"backup_controller_page_backup_sub": "Backed up photos and videos",
|
"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_cancel": "Cancel",
|
||||||
"backup_controller_page_created": "Created on: {}",
|
"backup_controller_page_created": "Created on: {}",
|
||||||
"backup_controller_page_desc_backup": "Turn on backup to automatically upload new assets to the server.",
|
"backup_controller_page_desc_backup": "Turn on backup to automatically upload new assets to the server.",
|
||||||
|
17
mobile/lib/constants/locales.dart
Normal file
17
mobile/lib/constants/locales.dart
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
const List<Locale> 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';
|
@ -7,6 +7,9 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_displaymode/flutter_displaymode.dart';
|
import 'package:flutter_displaymode/flutter_displaymode.dart';
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/models/hive_backup_albums.model.dart';
|
||||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||||
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
|
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
|
||||||
@ -43,20 +46,6 @@ void main() async {
|
|||||||
|
|
||||||
await EasyLocalization.ensureInitialized();
|
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) {
|
if (kReleaseMode && Platform.isAndroid) {
|
||||||
try {
|
try {
|
||||||
await FlutterDisplayMode.setHighRefreshRate();
|
await FlutterDisplayMode.setHighRefreshRate();
|
||||||
@ -68,7 +57,7 @@ void main() async {
|
|||||||
runApp(
|
runApp(
|
||||||
EasyLocalization(
|
EasyLocalization(
|
||||||
supportedLocales: locales,
|
supportedLocales: locales,
|
||||||
path: 'assets/i18n',
|
path: translationsPath,
|
||||||
useFallbackTranslations: true,
|
useFallbackTranslations: true,
|
||||||
fallbackLocale: locales.first,
|
fallbackLocale: locales.first,
|
||||||
child: const ProviderScope(child: ImmichApp()),
|
child: const ProviderScope(child: ImmichApp()),
|
||||||
@ -95,6 +84,7 @@ class ImmichAppState extends ConsumerState<ImmichApp>
|
|||||||
var isAuthenticated = ref.watch(authenticationProvider).isAuthenticated;
|
var isAuthenticated = ref.watch(authenticationProvider).isAuthenticated;
|
||||||
|
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
|
ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
|
||||||
ref.watch(backupProvider.notifier).resumeBackup();
|
ref.watch(backupProvider.notifier).resumeBackup();
|
||||||
ref.watch(assetProvider.notifier).getAllAsset();
|
ref.watch(assetProvider.notifier).getAllAsset();
|
||||||
ref.watch(serverInfoProvider.notifier).getServerVersion();
|
ref.watch(serverInfoProvider.notifier).getServerVersion();
|
||||||
@ -134,6 +124,10 @@ class ImmichAppState extends ConsumerState<ImmichApp>
|
|||||||
initState() {
|
initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
initApp().then((_) => debugPrint("App Init Completed"));
|
initApp().then((_) => debugPrint("App Init Completed"));
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
// needs to be delayed so that EasyLocalization is working
|
||||||
|
ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -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<bool> _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<bool> resumeServiceIfEnabled() async {
|
||||||
|
return await isBackgroundBackupEnabled() &&
|
||||||
|
await startService(keepExisting: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enqueues the background service
|
||||||
|
Future<bool> 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<bool> 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<bool> 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<bool> 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<bool> 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<bool> 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<bool> 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<void> _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<bool> _callHandler(MethodCall call) async {
|
||||||
|
switch (call.method) {
|
||||||
|
case "onAssetsChanged":
|
||||||
|
final Future<bool> 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<bool> _onAssetsChanged() async {
|
||||||
|
await Hive.initFlutter();
|
||||||
|
|
||||||
|
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
|
||||||
|
Hive.registerAdapter(HiveBackupAlbumsAdapter());
|
||||||
|
await Hive.openBox(userInfoBox);
|
||||||
|
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
|
||||||
|
|
||||||
|
ApiService apiService = ApiService();
|
||||||
|
apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
|
||||||
|
apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey));
|
||||||
|
BackupService backupService = BackupService(apiService);
|
||||||
|
|
||||||
|
final Box<HiveBackupAlbums> box =
|
||||||
|
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||||
|
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
|
||||||
|
if (backupAlbumInfo == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await PhotoManager.setIgnorePermissionCheck(true);
|
||||||
|
|
||||||
|
if (_canceledBySystem) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<AssetEntity> 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();
|
||||||
|
}
|
@ -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<bool> 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);
|
||||||
|
}
|
@ -4,35 +4,45 @@ import 'package:photo_manager/photo_manager.dart';
|
|||||||
|
|
||||||
class AvailableAlbum {
|
class AvailableAlbum {
|
||||||
final AssetPathEntity albumEntity;
|
final AssetPathEntity albumEntity;
|
||||||
|
final DateTime? lastBackup;
|
||||||
final Uint8List? thumbnailData;
|
final Uint8List? thumbnailData;
|
||||||
AvailableAlbum({
|
AvailableAlbum({
|
||||||
required this.albumEntity,
|
required this.albumEntity,
|
||||||
|
this.lastBackup,
|
||||||
this.thumbnailData,
|
this.thumbnailData,
|
||||||
});
|
});
|
||||||
|
|
||||||
AvailableAlbum copyWith({
|
AvailableAlbum copyWith({
|
||||||
AssetPathEntity? albumEntity,
|
AssetPathEntity? albumEntity,
|
||||||
|
DateTime? lastBackup,
|
||||||
Uint8List? thumbnailData,
|
Uint8List? thumbnailData,
|
||||||
}) {
|
}) {
|
||||||
return AvailableAlbum(
|
return AvailableAlbum(
|
||||||
albumEntity: albumEntity ?? this.albumEntity,
|
albumEntity: albumEntity ?? this.albumEntity,
|
||||||
|
lastBackup: lastBackup ?? this.lastBackup,
|
||||||
thumbnailData: thumbnailData ?? this.thumbnailData,
|
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
|
@override
|
||||||
String toString() =>
|
String toString() =>
|
||||||
'AvailableAlbum(albumEntity: $albumEntity, thumbnailData: $thumbnailData)';
|
'AvailableAlbum(albumEntity: $albumEntity, lastBackup: $lastBackup, thumbnailData: $thumbnailData)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (identical(this, other)) return true;
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
return other is AvailableAlbum &&
|
return other is AvailableAlbum && other.albumEntity == albumEntity;
|
||||||
other.albumEntity == albumEntity &&
|
|
||||||
other.thumbnailData == thumbnailData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => albumEntity.hashCode ^ thumbnailData.hashCode;
|
int get hashCode => albumEntity.hashCode;
|
||||||
}
|
}
|
||||||
|
@ -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/available_album.model.dart';
|
||||||
import 'package:immich_mobile/modules/backup/models/current_upload_asset.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 {
|
class BackUpState {
|
||||||
// enum
|
// enum
|
||||||
@ -15,11 +15,14 @@ class BackUpState {
|
|||||||
final double progressInPercentage;
|
final double progressInPercentage;
|
||||||
final CancellationToken cancelToken;
|
final CancellationToken cancelToken;
|
||||||
final ServerInfoResponseDto serverInfo;
|
final ServerInfoResponseDto serverInfo;
|
||||||
|
final bool backgroundBackup;
|
||||||
|
final bool backupRequireWifi;
|
||||||
|
final bool backupRequireCharging;
|
||||||
|
|
||||||
/// All available albums on the device
|
/// All available albums on the device
|
||||||
final List<AvailableAlbum> availableAlbums;
|
final List<AvailableAlbum> availableAlbums;
|
||||||
final Set<AssetPathEntity> selectedBackupAlbums;
|
final Set<AvailableAlbum> selectedBackupAlbums;
|
||||||
final Set<AssetPathEntity> excludedBackupAlbums;
|
final Set<AvailableAlbum> excludedBackupAlbums;
|
||||||
|
|
||||||
/// Assets that are not overlapping in selected backup albums and excluded backup albums
|
/// Assets that are not overlapping in selected backup albums and excluded backup albums
|
||||||
final Set<AssetEntity> allUniqueAssets;
|
final Set<AssetEntity> allUniqueAssets;
|
||||||
@ -36,6 +39,9 @@ class BackUpState {
|
|||||||
required this.progressInPercentage,
|
required this.progressInPercentage,
|
||||||
required this.cancelToken,
|
required this.cancelToken,
|
||||||
required this.serverInfo,
|
required this.serverInfo,
|
||||||
|
required this.backgroundBackup,
|
||||||
|
required this.backupRequireWifi,
|
||||||
|
required this.backupRequireCharging,
|
||||||
required this.availableAlbums,
|
required this.availableAlbums,
|
||||||
required this.selectedBackupAlbums,
|
required this.selectedBackupAlbums,
|
||||||
required this.excludedBackupAlbums,
|
required this.excludedBackupAlbums,
|
||||||
@ -50,9 +56,12 @@ class BackUpState {
|
|||||||
double? progressInPercentage,
|
double? progressInPercentage,
|
||||||
CancellationToken? cancelToken,
|
CancellationToken? cancelToken,
|
||||||
ServerInfoResponseDto? serverInfo,
|
ServerInfoResponseDto? serverInfo,
|
||||||
|
bool? backgroundBackup,
|
||||||
|
bool? backupRequireWifi,
|
||||||
|
bool? backupRequireCharging,
|
||||||
List<AvailableAlbum>? availableAlbums,
|
List<AvailableAlbum>? availableAlbums,
|
||||||
Set<AssetPathEntity>? selectedBackupAlbums,
|
Set<AvailableAlbum>? selectedBackupAlbums,
|
||||||
Set<AssetPathEntity>? excludedBackupAlbums,
|
Set<AvailableAlbum>? excludedBackupAlbums,
|
||||||
Set<AssetEntity>? allUniqueAssets,
|
Set<AssetEntity>? allUniqueAssets,
|
||||||
Set<String>? selectedAlbumsBackupAssetsIds,
|
Set<String>? selectedAlbumsBackupAssetsIds,
|
||||||
CurrentUploadAsset? currentUploadAsset,
|
CurrentUploadAsset? currentUploadAsset,
|
||||||
@ -63,6 +72,10 @@ class BackUpState {
|
|||||||
progressInPercentage: progressInPercentage ?? this.progressInPercentage,
|
progressInPercentage: progressInPercentage ?? this.progressInPercentage,
|
||||||
cancelToken: cancelToken ?? this.cancelToken,
|
cancelToken: cancelToken ?? this.cancelToken,
|
||||||
serverInfo: serverInfo ?? this.serverInfo,
|
serverInfo: serverInfo ?? this.serverInfo,
|
||||||
|
backgroundBackup: backgroundBackup ?? this.backgroundBackup,
|
||||||
|
backupRequireWifi: backupRequireWifi ?? this.backupRequireWifi,
|
||||||
|
backupRequireCharging:
|
||||||
|
backupRequireCharging ?? this.backupRequireCharging,
|
||||||
availableAlbums: availableAlbums ?? this.availableAlbums,
|
availableAlbums: availableAlbums ?? this.availableAlbums,
|
||||||
selectedBackupAlbums: selectedBackupAlbums ?? this.selectedBackupAlbums,
|
selectedBackupAlbums: selectedBackupAlbums ?? this.selectedBackupAlbums,
|
||||||
excludedBackupAlbums: excludedBackupAlbums ?? this.excludedBackupAlbums,
|
excludedBackupAlbums: excludedBackupAlbums ?? this.excludedBackupAlbums,
|
||||||
@ -75,7 +88,7 @@ class BackUpState {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
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
|
@override
|
||||||
@ -89,6 +102,9 @@ class BackUpState {
|
|||||||
other.progressInPercentage == progressInPercentage &&
|
other.progressInPercentage == progressInPercentage &&
|
||||||
other.cancelToken == cancelToken &&
|
other.cancelToken == cancelToken &&
|
||||||
other.serverInfo == serverInfo &&
|
other.serverInfo == serverInfo &&
|
||||||
|
other.backgroundBackup == backgroundBackup &&
|
||||||
|
other.backupRequireWifi == backupRequireWifi &&
|
||||||
|
other.backupRequireCharging == backupRequireCharging &&
|
||||||
collectionEquals(other.availableAlbums, availableAlbums) &&
|
collectionEquals(other.availableAlbums, availableAlbums) &&
|
||||||
collectionEquals(other.selectedBackupAlbums, selectedBackupAlbums) &&
|
collectionEquals(other.selectedBackupAlbums, selectedBackupAlbums) &&
|
||||||
collectionEquals(other.excludedBackupAlbums, excludedBackupAlbums) &&
|
collectionEquals(other.excludedBackupAlbums, excludedBackupAlbums) &&
|
||||||
@ -107,6 +123,9 @@ class BackUpState {
|
|||||||
progressInPercentage.hashCode ^
|
progressInPercentage.hashCode ^
|
||||||
cancelToken.hashCode ^
|
cancelToken.hashCode ^
|
||||||
serverInfo.hashCode ^
|
serverInfo.hashCode ^
|
||||||
|
backgroundBackup.hashCode ^
|
||||||
|
backupRequireWifi.hashCode ^
|
||||||
|
backupRequireCharging.hashCode ^
|
||||||
availableAlbums.hashCode ^
|
availableAlbums.hashCode ^
|
||||||
selectedBackupAlbums.hashCode ^
|
selectedBackupAlbums.hashCode ^
|
||||||
excludedBackupAlbums.hashCode ^
|
excludedBackupAlbums.hashCode ^
|
||||||
|
@ -13,9 +13,17 @@ class HiveBackupAlbums {
|
|||||||
@HiveField(1)
|
@HiveField(1)
|
||||||
List<String> excludedAlbumsIds;
|
List<String> excludedAlbumsIds;
|
||||||
|
|
||||||
|
@HiveField(2, defaultValue: [])
|
||||||
|
List<DateTime> lastSelectedBackupTime;
|
||||||
|
|
||||||
|
@HiveField(3, defaultValue: [])
|
||||||
|
List<DateTime> lastExcludedBackupTime;
|
||||||
|
|
||||||
HiveBackupAlbums({
|
HiveBackupAlbums({
|
||||||
required this.selectedAlbumIds,
|
required this.selectedAlbumIds,
|
||||||
required this.excludedAlbumsIds,
|
required this.excludedAlbumsIds,
|
||||||
|
required this.lastSelectedBackupTime,
|
||||||
|
required this.lastExcludedBackupTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -25,10 +33,16 @@ class HiveBackupAlbums {
|
|||||||
HiveBackupAlbums copyWith({
|
HiveBackupAlbums copyWith({
|
||||||
List<String>? selectedAlbumIds,
|
List<String>? selectedAlbumIds,
|
||||||
List<String>? excludedAlbumsIds,
|
List<String>? excludedAlbumsIds,
|
||||||
|
List<DateTime>? lastSelectedBackupTime,
|
||||||
|
List<DateTime>? lastExcludedBackupTime,
|
||||||
}) {
|
}) {
|
||||||
return HiveBackupAlbums(
|
return HiveBackupAlbums(
|
||||||
selectedAlbumIds: selectedAlbumIds ?? this.selectedAlbumIds,
|
selectedAlbumIds: selectedAlbumIds ?? this.selectedAlbumIds,
|
||||||
excludedAlbumsIds: excludedAlbumsIds ?? this.excludedAlbumsIds,
|
excludedAlbumsIds: excludedAlbumsIds ?? this.excludedAlbumsIds,
|
||||||
|
lastSelectedBackupTime:
|
||||||
|
lastSelectedBackupTime ?? this.lastSelectedBackupTime,
|
||||||
|
lastExcludedBackupTime:
|
||||||
|
lastExcludedBackupTime ?? this.lastExcludedBackupTime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,6 +51,8 @@ class HiveBackupAlbums {
|
|||||||
|
|
||||||
result.addAll({'selectedAlbumIds': selectedAlbumIds});
|
result.addAll({'selectedAlbumIds': selectedAlbumIds});
|
||||||
result.addAll({'excludedAlbumsIds': excludedAlbumsIds});
|
result.addAll({'excludedAlbumsIds': excludedAlbumsIds});
|
||||||
|
result.addAll({'lastSelectedBackupTime': lastSelectedBackupTime});
|
||||||
|
result.addAll({'lastExcludedBackupTime': lastExcludedBackupTime});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@ -45,6 +61,10 @@ class HiveBackupAlbums {
|
|||||||
return HiveBackupAlbums(
|
return HiveBackupAlbums(
|
||||||
selectedAlbumIds: List<String>.from(map['selectedAlbumIds']),
|
selectedAlbumIds: List<String>.from(map['selectedAlbumIds']),
|
||||||
excludedAlbumsIds: List<String>.from(map['excludedAlbumsIds']),
|
excludedAlbumsIds: List<String>.from(map['excludedAlbumsIds']),
|
||||||
|
lastSelectedBackupTime:
|
||||||
|
List<DateTime>.from(map['lastSelectedBackupTime']),
|
||||||
|
lastExcludedBackupTime:
|
||||||
|
List<DateTime>.from(map['lastExcludedBackupTime']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,9 +80,15 @@ class HiveBackupAlbums {
|
|||||||
|
|
||||||
return other is HiveBackupAlbums &&
|
return other is HiveBackupAlbums &&
|
||||||
listEquals(other.selectedAlbumIds, selectedAlbumIds) &&
|
listEquals(other.selectedAlbumIds, selectedAlbumIds) &&
|
||||||
listEquals(other.excludedAlbumsIds, excludedAlbumsIds);
|
listEquals(other.excludedAlbumsIds, excludedAlbumsIds) &&
|
||||||
|
listEquals(other.lastSelectedBackupTime, lastSelectedBackupTime) &&
|
||||||
|
listEquals(other.lastExcludedBackupTime, lastExcludedBackupTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => selectedAlbumIds.hashCode ^ excludedAlbumsIds.hashCode;
|
int get hashCode =>
|
||||||
|
selectedAlbumIds.hashCode ^
|
||||||
|
excludedAlbumsIds.hashCode ^
|
||||||
|
lastSelectedBackupTime.hashCode ^
|
||||||
|
lastExcludedBackupTime.hashCode;
|
||||||
}
|
}
|
||||||
|
Binary file not shown.
@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:cancellation_token_http/http.dart';
|
import 'package:cancellation_token_http/http.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:hive_flutter/hive_flutter.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/error_upload_asset.model.dart';
|
||||||
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.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/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/backup/services/backup.service.dart';
|
||||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.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/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:immich_mobile/shared/services/server_info.service.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
@ -21,6 +25,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
this._backupService,
|
this._backupService,
|
||||||
this._serverInfoService,
|
this._serverInfoService,
|
||||||
this._authState,
|
this._authState,
|
||||||
|
this._backgroundService,
|
||||||
this.ref,
|
this.ref,
|
||||||
) : super(
|
) : super(
|
||||||
BackUpState(
|
BackUpState(
|
||||||
@ -28,6 +33,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
allAssetsInDatabase: const [],
|
allAssetsInDatabase: const [],
|
||||||
progressInPercentage: 0,
|
progressInPercentage: 0,
|
||||||
cancelToken: CancellationToken(),
|
cancelToken: CancellationToken(),
|
||||||
|
backgroundBackup: false,
|
||||||
|
backupRequireWifi: true,
|
||||||
|
backupRequireCharging: false,
|
||||||
serverInfo: ServerInfoResponseDto(
|
serverInfo: ServerInfoResponseDto(
|
||||||
diskAvailable: "0",
|
diskAvailable: "0",
|
||||||
diskAvailableRaw: 0,
|
diskAvailableRaw: 0,
|
||||||
@ -56,6 +64,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
final BackupService _backupService;
|
final BackupService _backupService;
|
||||||
final ServerInfoService _serverInfoService;
|
final ServerInfoService _serverInfoService;
|
||||||
final AuthenticationState _authState;
|
final AuthenticationState _authState;
|
||||||
|
final BackgroundService _backgroundService;
|
||||||
final Ref ref;
|
final Ref ref;
|
||||||
|
|
||||||
///
|
///
|
||||||
@ -66,7 +75,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
/// We have method to include and exclude albums
|
/// We have method to include and exclude albums
|
||||||
/// The total unique assets will be used for backing mechanism
|
/// The total unique assets will be used for backing mechanism
|
||||||
///
|
///
|
||||||
void addAlbumForBackup(AssetPathEntity album) {
|
void addAlbumForBackup(AvailableAlbum album) {
|
||||||
if (state.excludedBackupAlbums.contains(album)) {
|
if (state.excludedBackupAlbums.contains(album)) {
|
||||||
removeExcludedAlbumForBackup(album);
|
removeExcludedAlbumForBackup(album);
|
||||||
}
|
}
|
||||||
@ -76,7 +85,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
_updateBackupAssetCount();
|
_updateBackupAssetCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
void addExcludedAlbumForBackup(AssetPathEntity album) {
|
void addExcludedAlbumForBackup(AvailableAlbum album) {
|
||||||
if (state.selectedBackupAlbums.contains(album)) {
|
if (state.selectedBackupAlbums.contains(album)) {
|
||||||
removeAlbumForBackup(album);
|
removeAlbumForBackup(album);
|
||||||
}
|
}
|
||||||
@ -85,8 +94,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
_updateBackupAssetCount();
|
_updateBackupAssetCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
void removeAlbumForBackup(AssetPathEntity album) {
|
void removeAlbumForBackup(AvailableAlbum album) {
|
||||||
Set<AssetPathEntity> currentSelectedAlbums = state.selectedBackupAlbums;
|
Set<AvailableAlbum> currentSelectedAlbums = state.selectedBackupAlbums;
|
||||||
|
|
||||||
currentSelectedAlbums.removeWhere((a) => a == album);
|
currentSelectedAlbums.removeWhere((a) => a == album);
|
||||||
|
|
||||||
@ -94,8 +103,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
_updateBackupAssetCount();
|
_updateBackupAssetCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
void removeExcludedAlbumForBackup(AssetPathEntity album) {
|
void removeExcludedAlbumForBackup(AvailableAlbum album) {
|
||||||
Set<AssetPathEntity> currentExcludedAlbums = state.excludedBackupAlbums;
|
Set<AvailableAlbum> currentExcludedAlbums = state.excludedBackupAlbums;
|
||||||
|
|
||||||
currentExcludedAlbums.removeWhere((a) => a == album);
|
currentExcludedAlbums.removeWhere((a) => a == album);
|
||||||
|
|
||||||
@ -103,6 +112,50 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
_updateBackupAssetCount();
|
_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 album on the device
|
||||||
/// Get all selected and excluded album from the user's persistent storage
|
/// Get all selected and excluded album from the user's persistent storage
|
||||||
@ -144,6 +197,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
defaultValue: HiveBackupAlbums(
|
defaultValue: HiveBackupAlbums(
|
||||||
selectedAlbumIds: [],
|
selectedAlbumIds: [],
|
||||||
excludedAlbumsIds: [],
|
excludedAlbumsIds: [],
|
||||||
|
lastSelectedBackupTime: [],
|
||||||
|
lastExcludedBackupTime: [],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -173,6 +228,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
HiveBackupAlbums(
|
HiveBackupAlbums(
|
||||||
selectedAlbumIds: [albumHasAllAssets.id],
|
selectedAlbumIds: [albumHasAllAssets.id],
|
||||||
excludedAlbumsIds: [],
|
excludedAlbumsIds: [],
|
||||||
|
lastSelectedBackupTime: [
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(0, isUtc: true)
|
||||||
|
],
|
||||||
|
lastExcludedBackupTime: [],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -181,19 +240,37 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
|
|
||||||
// Generate AssetPathEntity from id to add to local state
|
// Generate AssetPathEntity from id to add to local state
|
||||||
try {
|
try {
|
||||||
for (var selectedAlbumId in backupAlbumInfo!.selectedAlbumIds) {
|
Set<AvailableAlbum> selectedAlbums = {};
|
||||||
var albumAsset = await AssetPathEntity.fromId(selectedAlbumId);
|
for (var i = 0; i < backupAlbumInfo!.selectedAlbumIds.length; i++) {
|
||||||
state = state.copyWith(
|
var albumAsset =
|
||||||
selectedBackupAlbums: {...state.selectedBackupAlbums, 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) {
|
Set<AvailableAlbum> excludedAlbums = {};
|
||||||
var albumAsset = await AssetPathEntity.fromId(excludedAlbumId);
|
for (var i = 0; i < backupAlbumInfo.excludedAlbumsIds.length; i++) {
|
||||||
state = state.copyWith(
|
var albumAsset =
|
||||||
excludedBackupAlbums: {...state.excludedBackupAlbums, 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) {
|
} catch (e) {
|
||||||
debugPrint("[ERROR] Failed to generate album from id $e");
|
debugPrint("[ERROR] Failed to generate album from id $e");
|
||||||
}
|
}
|
||||||
@ -209,14 +286,14 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
Set<AssetEntity> assetsFromExcludedAlbums = {};
|
Set<AssetEntity> assetsFromExcludedAlbums = {};
|
||||||
|
|
||||||
for (var album in state.selectedBackupAlbums) {
|
for (var album in state.selectedBackupAlbums) {
|
||||||
var assets =
|
var assets = await album.albumEntity
|
||||||
await album.getAssetListRange(start: 0, end: album.assetCount);
|
.getAssetListRange(start: 0, end: album.assetCount);
|
||||||
assetsFromSelectedAlbums.addAll(assets);
|
assetsFromSelectedAlbums.addAll(assets);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (var album in state.excludedBackupAlbums) {
|
for (var album in state.excludedBackupAlbums) {
|
||||||
var assets =
|
var assets = await album.albumEntity
|
||||||
await album.getAssetListRange(start: 0, end: album.assetCount);
|
.getAssetListRange(start: 0, end: album.assetCount);
|
||||||
assetsFromExcludedAlbums.addAll(assets);
|
assetsFromExcludedAlbums.addAll(assets);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -263,6 +340,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
/// and then update the UI according to those information
|
/// and then update the UI according to those information
|
||||||
///
|
///
|
||||||
Future<void> getBackupInfo() async {
|
Future<void> getBackupInfo() async {
|
||||||
|
final bool isEnabled = await _backgroundService.isBackgroundBackupEnabled();
|
||||||
|
state = state.copyWith(backgroundBackup: isEnabled);
|
||||||
|
if (state.backupProgress != BackUpProgressEnum.inBackground) {
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
_getBackupAlbumsInfo(),
|
_getBackupAlbumsInfo(),
|
||||||
_updateServerInfo(),
|
_updateServerInfo(),
|
||||||
@ -270,12 +350,14 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
|
|
||||||
await _updateBackupAssetCount();
|
await _updateBackupAssetCount();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
///
|
///
|
||||||
/// Save user selection of selected albums and excluded albums to
|
/// Save user selection of selected albums and excluded albums to
|
||||||
/// Hive database
|
/// Hive database
|
||||||
///
|
///
|
||||||
void _updatePersistentAlbumsSelection() {
|
void _updatePersistentAlbumsSelection() {
|
||||||
|
final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
||||||
Box<HiveBackupAlbums> backupAlbumInfoBox =
|
Box<HiveBackupAlbums> backupAlbumInfoBox =
|
||||||
Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
|
Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||||
backupAlbumInfoBox.put(
|
backupAlbumInfoBox.put(
|
||||||
@ -283,6 +365,12 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
HiveBackupAlbums(
|
HiveBackupAlbums(
|
||||||
selectedAlbumIds: state.selectedBackupAlbums.map((e) => e.id).toList(),
|
selectedAlbumIds: state.selectedBackupAlbums.map((e) => e.id).toList(),
|
||||||
excludedAlbumsIds: state.excludedBackupAlbums.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<BackUpState> {
|
|||||||
///
|
///
|
||||||
/// Invoke backup process
|
/// Invoke backup process
|
||||||
///
|
///
|
||||||
void startBackupProcess() async {
|
Future<void> startBackupProcess() async {
|
||||||
|
assert(state.backupProgress == BackUpProgressEnum.idle);
|
||||||
state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
|
state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
|
||||||
|
|
||||||
await getBackupInfo();
|
await getBackupInfo();
|
||||||
@ -318,7 +407,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
|
|
||||||
// Perform Backup
|
// Perform Backup
|
||||||
state = state.copyWith(cancelToken: CancellationToken());
|
state = state.copyWith(cancelToken: CancellationToken());
|
||||||
_backupService.backupAsset(
|
await _backupService.backupAsset(
|
||||||
assetsWillBeBackup,
|
assetsWillBeBackup,
|
||||||
state.cancelToken,
|
state.cancelToken,
|
||||||
_onAssetUploaded,
|
_onAssetUploaded,
|
||||||
@ -326,6 +415,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
_onSetCurrentBackupAsset,
|
_onSetCurrentBackupAsset,
|
||||||
_onBackupError,
|
_onBackupError,
|
||||||
);
|
);
|
||||||
|
await _notifyBackgroundServiceCanRun();
|
||||||
} else {
|
} else {
|
||||||
PhotoManager.openSetting();
|
PhotoManager.openSetting();
|
||||||
}
|
}
|
||||||
@ -340,6 +430,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void cancelBackup() {
|
void cancelBackup() {
|
||||||
|
if (state.backupProgress != BackUpProgressEnum.inProgress) {
|
||||||
|
_notifyBackgroundServiceCanRun();
|
||||||
|
}
|
||||||
state.cancelToken.cancel();
|
state.cancelToken.cancel();
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
backupProgress: BackUpProgressEnum.idle,
|
backupProgress: BackUpProgressEnum.idle,
|
||||||
@ -359,10 +452,21 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
if (state.allUniqueAssets.length -
|
if (state.allUniqueAssets.length -
|
||||||
state.selectedAlbumsBackupAssetsIds.length ==
|
state.selectedAlbumsBackupAssetsIds.length ==
|
||||||
0) {
|
0) {
|
||||||
|
final latestAssetBackup =
|
||||||
|
state.allUniqueAssets.map((e) => e.modifiedDateTime).reduce(
|
||||||
|
(v, e) => e.isAfter(v) ? e : v,
|
||||||
|
);
|
||||||
state = state.copyWith(
|
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,
|
backupProgress: BackUpProgressEnum.done,
|
||||||
progressInPercentage: 0.0,
|
progressInPercentage: 0.0,
|
||||||
);
|
);
|
||||||
|
_updatePersistentAlbumsSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
_updateServerInfo();
|
_updateServerInfo();
|
||||||
@ -385,7 +489,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void resumeBackup() {
|
Future<void> _resumeBackup() async {
|
||||||
// Check if user is login
|
// Check if user is login
|
||||||
var accessKey = Hive.box(userInfoBox).get(accessTokenKey);
|
var accessKey = Hive.box(userInfoBox).get(accessTokenKey);
|
||||||
|
|
||||||
@ -404,13 +508,91 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.backupProgress == BackUpProgressEnum.inBackground) {
|
||||||
|
debugPrint("[resumeBackup] Background backup is running - abort");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Run backup
|
// Run backup
|
||||||
debugPrint("[resumeBackup] Start back up");
|
debugPrint("[resumeBackup] Start back up");
|
||||||
startBackupProcess();
|
await startBackupProcess();
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> 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<HiveBackupAlbums> box =
|
||||||
|
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||||
|
HiveBackupAlbums? albums = box.get(backupInfoKey);
|
||||||
|
Set<AvailableAlbum> selectedAlbums = state.selectedBackupAlbums;
|
||||||
|
Set<AvailableAlbum> 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<AvailableAlbum> _updateAlbumsBackupTime(
|
||||||
|
Set<AvailableAlbum> albums,
|
||||||
|
List<String> ids,
|
||||||
|
List<DateTime> times,
|
||||||
|
) {
|
||||||
|
Set<AvailableAlbum> 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<void> _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<HiveBackupAlbums>(hiveBackupInfoBox).close();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
|
||||||
|
}
|
||||||
|
_backgroundService.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final backupProvider =
|
final backupProvider =
|
||||||
@ -419,6 +601,7 @@ final backupProvider =
|
|||||||
ref.watch(backupServiceProvider),
|
ref.watch(backupServiceProvider),
|
||||||
ref.watch(serverInfoServiceProvider),
|
ref.watch(serverInfoServiceProvider),
|
||||||
ref.watch(authenticationProvider),
|
ref.watch(authenticationProvider),
|
||||||
|
ref.watch(backgroundServiceProvider),
|
||||||
ref,
|
ref,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hive/hive.dart';
|
import 'package:hive/hive.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/current_upload_asset.model.dart';
|
||||||
import 'package:immich_mobile/modules/backup/models/error_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/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/shared/services/api.service.dart';
|
||||||
import 'package:immich_mobile/utils/files_helper.dart';
|
import 'package:immich_mobile/utils/files_helper.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
@ -39,8 +41,141 @@ class BackupService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
backupAsset(
|
/// Returns all assets to backup from the backup info taking into account the
|
||||||
Set<AssetEntity> assetList,
|
/// time of the last successfull backup per album
|
||||||
|
Future<List<AssetEntity>> getAssetsToBackup(
|
||||||
|
HiveBackupAlbums backupAlbumInfo,
|
||||||
|
) async {
|
||||||
|
final List<AssetEntity> candidates =
|
||||||
|
await _buildUploadCandidates(backupAlbumInfo);
|
||||||
|
|
||||||
|
final List<AssetEntity> toUpload = candidates.isEmpty
|
||||||
|
? []
|
||||||
|
: await _removeAlreadyUploadedAssets(candidates);
|
||||||
|
return toUpload;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<AssetEntity>> _buildUploadCandidates(
|
||||||
|
HiveBackupAlbums backupAlbums,
|
||||||
|
) async {
|
||||||
|
final filter = FilterOptionGroup(
|
||||||
|
containsPathModified: true,
|
||||||
|
orders: [const OrderOption(type: OrderOptionType.updateDate)],
|
||||||
|
);
|
||||||
|
final now = DateTime.now();
|
||||||
|
final List<AssetPathEntity?> 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<AssetPathEntity?> excludedAlbums =
|
||||||
|
await _loadAlbumsWithTimeFilter(
|
||||||
|
backupAlbums.excludedAlbumsIds,
|
||||||
|
backupAlbums.lastExcludedBackupTime,
|
||||||
|
filter,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
final List<AssetEntity> toAdd = await _fetchAssetsAndUpdateLastBackup(
|
||||||
|
selectedAlbums.slice(allIdx, allIdx + 1),
|
||||||
|
backupAlbums.lastSelectedBackupTime.slice(allIdx, allIdx + 1),
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
final List<AssetEntity> toRemove = await _fetchAssetsAndUpdateLastBackup(
|
||||||
|
excludedAlbums,
|
||||||
|
backupAlbums.lastExcludedBackupTime,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
return toAdd.toSet().difference(toRemove.toSet()).toList();
|
||||||
|
} else {
|
||||||
|
return await _fetchAssetsAndUpdateLastBackup(
|
||||||
|
selectedAlbums,
|
||||||
|
backupAlbums.lastSelectedBackupTime,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<AssetPathEntity?>> _loadAlbumsWithTimeFilter(
|
||||||
|
List<String> albumIds,
|
||||||
|
List<DateTime> lastBackups,
|
||||||
|
FilterOptionGroup filter,
|
||||||
|
DateTime now,
|
||||||
|
) async {
|
||||||
|
List<AssetPathEntity?> 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<List<AssetEntity>> _fetchAssetsAndUpdateLastBackup(
|
||||||
|
List<AssetPathEntity?> albums,
|
||||||
|
List<DateTime> lastBackup,
|
||||||
|
DateTime now,
|
||||||
|
) async {
|
||||||
|
List<AssetEntity> 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<List<AssetEntity>> _removeAlreadyUploadedAssets(
|
||||||
|
List<AssetEntity> candidates,
|
||||||
|
) async {
|
||||||
|
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
||||||
|
if (candidates.length < 10) {
|
||||||
|
final List<CheckDuplicateAssetResponseDto?> 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<String>? allAssetsInDatabase = await getDeviceBackupAsset();
|
||||||
|
|
||||||
|
if (allAssetsInDatabase == null) {
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
final Set<String> inDb = allAssetsInDatabase.toSet();
|
||||||
|
return candidates.whereNot((e) => inDb.contains(e.id)).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> backupAsset(
|
||||||
|
Iterable<AssetEntity> assetList,
|
||||||
http.CancellationToken cancelToken,
|
http.CancellationToken cancelToken,
|
||||||
Function(String, String) singleAssetDoneCb,
|
Function(String, String) singleAssetDoneCb,
|
||||||
Function(int, int) uploadProgressCb,
|
Function(int, int) uploadProgressCb,
|
||||||
@ -50,6 +185,7 @@ class BackupService {
|
|||||||
String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
||||||
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
||||||
File? file;
|
File? file;
|
||||||
|
bool anyErrors = false;
|
||||||
|
|
||||||
for (var entity in assetList) {
|
for (var entity in assetList) {
|
||||||
try {
|
try {
|
||||||
@ -60,7 +196,8 @@ class BackupService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
String originalFileName = await entity.titleAsync;
|
String originalFileName =
|
||||||
|
entity.title != null ? entity.title! : await entity.titleAsync;
|
||||||
String fileNameWithoutPath =
|
String fileNameWithoutPath =
|
||||||
originalFileName.toString().split(".")[0];
|
originalFileName.toString().split(".")[0];
|
||||||
var fileExtension = p.extension(file.path);
|
var fileExtension = p.extension(file.path);
|
||||||
@ -134,9 +271,10 @@ class BackupService {
|
|||||||
}
|
}
|
||||||
} on http.CancelledException {
|
} on http.CancelledException {
|
||||||
debugPrint("Backup was cancelled by the user");
|
debugPrint("Backup was cancelled by the user");
|
||||||
return;
|
return false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("ERROR backupAsset: ${e.toString()}");
|
debugPrint("ERROR backupAsset: ${e.toString()}");
|
||||||
|
anyErrors = true;
|
||||||
continue;
|
continue;
|
||||||
} finally {
|
} finally {
|
||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
@ -144,6 +282,7 @@ class BackupService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return !anyErrors;
|
||||||
}
|
}
|
||||||
|
|
||||||
String _getAssetType(AssetType assetType) {
|
String _getAssetType(AssetType assetType) {
|
||||||
|
@ -6,14 +6,14 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/modules/backup/providers/backup.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
|
||||||
|
|
||||||
class AlbumInfoCard extends HookConsumerWidget {
|
class AlbumInfoCard extends HookConsumerWidget {
|
||||||
final Uint8List? imageData;
|
final Uint8List? imageData;
|
||||||
final AssetPathEntity albumInfo;
|
final AvailableAlbum albumInfo;
|
||||||
|
|
||||||
const AlbumInfoCard({Key? key, this.imageData, required this.albumInfo})
|
const AlbumInfoCard({Key? key, this.imageData, required this.albumInfo})
|
||||||
: super(key: key);
|
: super(key: key);
|
||||||
@ -223,7 +223,7 @@ class AlbumInfoCard extends HookConsumerWidget {
|
|||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
AutoRouter.of(context).push(
|
AutoRouter.of(context).push(
|
||||||
AlbumPreviewRoute(album: albumInfo),
|
AlbumPreviewRoute(album: albumInfo.albumEntity),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
|
@ -48,7 +48,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
|||||||
: const EdgeInsets.all(0),
|
: const EdgeInsets.all(0),
|
||||||
child: AlbumInfoCard(
|
child: AlbumInfoCard(
|
||||||
imageData: thumbnailData,
|
imageData: thumbnailData,
|
||||||
albumInfo: availableAlbums[index].albumEntity,
|
albumInfo: availableAlbums[index],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@ -20,9 +22,12 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
BackUpState backupState = ref.watch(backupProvider);
|
BackUpState backupState = ref.watch(backupProvider);
|
||||||
AuthenticationState authenticationState = ref.watch(authenticationProvider);
|
AuthenticationState authenticationState = ref.watch(authenticationProvider);
|
||||||
|
bool hasExclusiveAccess =
|
||||||
|
backupState.backupProgress != BackUpProgressEnum.inBackground;
|
||||||
bool shouldBackup = backupState.allUniqueAssets.length -
|
bool shouldBackup = backupState.allUniqueAssets.length -
|
||||||
backupState.selectedAlbumsBackupAssetsIds.length ==
|
backupState.selectedAlbumsBackupAssetsIds.length ==
|
||||||
0
|
0 ||
|
||||||
|
!hasExclusiveAccess
|
||||||
? false
|
? false
|
||||||
: true;
|
: 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() {
|
Widget _buildSelectedAlbumName() {
|
||||||
var text = "backup_controller_page_backup_selected".tr();
|
var text = "backup_controller_page_backup_selected".tr();
|
||||||
var albums = ref.watch(backupProvider).selectedBackupAlbums;
|
var albums = ref.watch(backupProvider).selectedBackupAlbums;
|
||||||
@ -237,9 +335,12 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
trailing: ElevatedButton(
|
trailing: ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: hasExclusiveAccess
|
||||||
AutoRouter.of(context).push(const BackupAlbumSelectionRoute());
|
? () {
|
||||||
},
|
AutoRouter.of(context)
|
||||||
|
.push(const BackupAlbumSelectionRoute());
|
||||||
|
}
|
||||||
|
: null,
|
||||||
child: const Text(
|
child: const Text(
|
||||||
"backup_controller_page_select",
|
"backup_controller_page_select",
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@ -400,8 +501,11 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
void startBackup() {
|
void startBackup() {
|
||||||
ref.watch(errorBackupListProvider.notifier).empty();
|
ref.watch(errorBackupListProvider.notifier).empty();
|
||||||
|
if (ref.watch(backupProvider).backupProgress !=
|
||||||
|
BackUpProgressEnum.inBackground) {
|
||||||
ref.watch(backupProvider.notifier).startBackupProcess();
|
ref.watch(backupProvider.notifier).startBackupProcess();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@ -433,6 +537,27 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||||
).tr(),
|
).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(),
|
_buildFolderSelectionTile(),
|
||||||
BackupInfoCard(
|
BackupInfoCard(
|
||||||
title: "backup_controller_page_total".tr(),
|
title: "backup_controller_page_total".tr(),
|
||||||
@ -452,6 +577,8 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
_buildAutoBackupController(),
|
_buildAutoBackupController(),
|
||||||
|
if (Platform.isAndroid) const Divider(),
|
||||||
|
if (Platform.isAndroid) _buildBackgroundBackupController(),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
_buildStorageInformation(),
|
_buildStorageInformation(),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
|
@ -35,7 +35,7 @@ packages:
|
|||||||
name: async
|
name: async
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.8.2"
|
version: "2.9.0"
|
||||||
auto_route:
|
auto_route:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
Loading…
Reference in New Issue
Block a user