Compare commits
8 Commits
feat/windo
...
refactor/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3946e8c0b2 | ||
|
|
1dbac30993 | ||
|
|
831fb5a2f9 | ||
|
|
8d272f8abc | ||
|
|
3a2b572e0b | ||
|
|
942b27241a | ||
|
|
482526475b | ||
|
|
c4bd24277a |
4
.github/pull_request_template.md
vendored
4
.github/pull_request_template.md
vendored
@@ -34,7 +34,3 @@ The `/api/something` endpoint is now `/api/something-else`
|
||||
- [ ] I have followed naming conventions/patterns in the surrounding code
|
||||
- [ ] All code in `src/services/` uses repositories implementations for database calls, filesystem operations, etc.
|
||||
- [ ] All code in `src/repositories/` is pretty basic/simple and does not have any immich specific logic (that belongs in `src/services/`)
|
||||
|
||||
## Please describe to which degree, if any, an LLM was used in creating this pull request.
|
||||
|
||||
...
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -138,7 +138,7 @@ jobs:
|
||||
name: Unit Test CLI (Windows)
|
||||
needs: pre-job
|
||||
if: ${{ needs.pre-job.outputs.should_run_cli == 'true' }}
|
||||
runs-on: windows-2025
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
|
||||
@@ -61,8 +61,9 @@ private open class BackgroundWorkerPigeonCodec : StandardMessageCodec() {
|
||||
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface BackgroundWorkerFgHostApi {
|
||||
fun enable()
|
||||
fun disable()
|
||||
fun enableSyncWorker()
|
||||
fun enableUploadWorker(callbackHandle: Long)
|
||||
fun disableUploadWorker()
|
||||
|
||||
companion object {
|
||||
/** The codec used by BackgroundWorkerFgHostApi. */
|
||||
@@ -74,11 +75,11 @@ interface BackgroundWorkerFgHostApi {
|
||||
fun setUp(binaryMessenger: BinaryMessenger, api: BackgroundWorkerFgHostApi?, messageChannelSuffix: String = "") {
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable$separatedMessageChannelSuffix", codec)
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableSyncWorker$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
api.enable()
|
||||
api.enableSyncWorker()
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
BackgroundWorkerPigeonUtils.wrapError(exception)
|
||||
@@ -90,11 +91,29 @@ interface BackgroundWorkerFgHostApi {
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$separatedMessageChannelSuffix", codec)
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val callbackHandleArg = args[0] as Long
|
||||
val wrapped: List<Any?> = try {
|
||||
api.enableUploadWorker(callbackHandleArg)
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
BackgroundWorkerPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disableUploadWorker$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
api.disable()
|
||||
api.disableUploadWorker()
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
BackgroundWorkerPigeonUtils.wrapError(exception)
|
||||
@@ -111,7 +130,6 @@ interface BackgroundWorkerFgHostApi {
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface BackgroundWorkerBgHostApi {
|
||||
fun onInitialized()
|
||||
fun close()
|
||||
|
||||
companion object {
|
||||
/** The codec used by BackgroundWorkerBgHostApi. */
|
||||
@@ -138,22 +156,6 @@ interface BackgroundWorkerBgHostApi {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.close$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
api.close()
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
BackgroundWorkerPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,6 +167,23 @@ class BackgroundWorkerFlutterApi(private val binaryMessenger: BinaryMessenger, p
|
||||
BackgroundWorkerPigeonCodec()
|
||||
}
|
||||
}
|
||||
fun onLocalSync(maxSecondsArg: Long?, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onLocalSync$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(maxSecondsArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
} else {
|
||||
callback(Result.success(Unit))
|
||||
}
|
||||
} else {
|
||||
callback(Result.failure(BackgroundWorkerPigeonUtils.createConnectionError(channelName)))
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onIosUpload(isRefreshArg: Boolean, maxSecondsArg: Long?, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
|
||||
@@ -11,11 +11,17 @@ import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.google.common.util.concurrent.SettableFuture
|
||||
import io.flutter.FlutterInjector
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.dart.DartExecutor
|
||||
import io.flutter.embedding.engine.dart.DartExecutor.DartCallback
|
||||
import io.flutter.embedding.engine.loader.FlutterLoader
|
||||
import io.flutter.view.FlutterCallbackInformation
|
||||
|
||||
private const val TAG = "BackgroundWorker"
|
||||
|
||||
enum class BackgroundTaskType {
|
||||
LOCAL_SYNC,
|
||||
UPLOAD,
|
||||
}
|
||||
|
||||
class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||
ListenableWorker(context, params), BackgroundWorkerBgHostApi {
|
||||
private val ctx: Context = context.applicationContext
|
||||
@@ -52,6 +58,25 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||
loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
|
||||
engine = FlutterEngine(ctx)
|
||||
|
||||
// Retrieve the callback handle stored by the main Flutter app
|
||||
// This handle points to the Flutter function that should be executed in the background
|
||||
val callbackHandle =
|
||||
ctx.getSharedPreferences(BackgroundWorkerApiImpl.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.getLong(BackgroundWorkerApiImpl.SHARED_PREF_CALLBACK_HANDLE, 0L)
|
||||
|
||||
if (callbackHandle == 0L) {
|
||||
// Without a valid callback handle, we cannot start the Flutter background execution
|
||||
complete(Result.failure())
|
||||
return@ensureInitializationCompleteAsync
|
||||
}
|
||||
|
||||
// Start the Flutter engine with the specified callback as the entry point
|
||||
val callback = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle)
|
||||
if (callback == null) {
|
||||
complete(Result.failure())
|
||||
return@ensureInitializationCompleteAsync
|
||||
}
|
||||
|
||||
// Register custom plugins
|
||||
MainActivity.registerPlugins(ctx, engine!!)
|
||||
flutterApi =
|
||||
@@ -61,12 +86,8 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||
api = this
|
||||
)
|
||||
|
||||
engine!!.dartExecutor.executeDartEntrypoint(
|
||||
DartExecutor.DartEntrypoint(
|
||||
loader.findAppBundlePath(),
|
||||
"package:immich_mobile/domain/services/background_worker.service.dart",
|
||||
"backgroundSyncNativeEntrypoint"
|
||||
)
|
||||
engine!!.dartExecutor.executeDartCallback(
|
||||
DartCallback(ctx.assets, loader.findAppBundlePath(), callback)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -79,10 +100,23 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||
* This method acts as a bridge between the native Android background task system and Flutter.
|
||||
*/
|
||||
override fun onInitialized() {
|
||||
flutterApi?.onAndroidUpload { handleHostResult(it) }
|
||||
val taskTypeIndex = inputData.getInt(BackgroundWorkerApiImpl.WORKER_DATA_TASK_TYPE, 0)
|
||||
val taskType = BackgroundTaskType.entries[taskTypeIndex]
|
||||
|
||||
when (taskType) {
|
||||
BackgroundTaskType.LOCAL_SYNC -> flutterApi?.onLocalSync(null) { handleHostResult(it) }
|
||||
BackgroundTaskType.UPLOAD -> flutterApi?.onAndroidUpload { handleHostResult(it) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
/**
|
||||
* Called when the system has to stop this worker because constraints are
|
||||
* no longer met or the system needs resources for more important tasks
|
||||
* This is also called when the worker has been explicitly cancelled or replaced
|
||||
*/
|
||||
override fun onStopped() {
|
||||
Log.d(TAG, "About to stop BackupWorker")
|
||||
|
||||
if (isComplete) {
|
||||
return
|
||||
}
|
||||
@@ -100,16 +134,6 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the system has to stop this worker because constraints are
|
||||
* no longer met or the system needs resources for more important tasks
|
||||
* This is also called when the worker has been explicitly cancelled or replaced
|
||||
*/
|
||||
override fun onStopped() {
|
||||
Log.d(TAG, "About to stop BackupWorker")
|
||||
close()
|
||||
}
|
||||
|
||||
private fun handleHostResult(result: kotlin.Result<Unit>) {
|
||||
if (isComplete) {
|
||||
return
|
||||
|
||||
@@ -3,8 +3,10 @@ package app.alextran.immich.background
|
||||
import android.content.Context
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.core.content.edit
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
@@ -14,13 +16,19 @@ private const val TAG = "BackgroundUploadImpl"
|
||||
|
||||
class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
||||
private val ctx: Context = context.applicationContext
|
||||
|
||||
override fun enable() {
|
||||
override fun enableSyncWorker() {
|
||||
enqueueMediaObserver(ctx)
|
||||
Log.i(TAG, "Scheduled media observer")
|
||||
}
|
||||
|
||||
override fun disable() {
|
||||
WorkManager.getInstance(ctx).cancelUniqueWork(OBSERVER_WORKER_NAME)
|
||||
override fun enableUploadWorker(callbackHandle: Long) {
|
||||
updateUploadEnabled(ctx, true)
|
||||
updateCallbackHandle(ctx, callbackHandle)
|
||||
Log.i(TAG, "Scheduled background upload tasks")
|
||||
}
|
||||
|
||||
override fun disableUploadWorker() {
|
||||
updateUploadEnabled(ctx, false)
|
||||
WorkManager.getInstance(ctx).cancelUniqueWork(BACKGROUND_WORKER_NAME)
|
||||
Log.i(TAG, "Cancelled background upload tasks")
|
||||
}
|
||||
@@ -29,14 +37,32 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
||||
private const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1"
|
||||
private const val OBSERVER_WORKER_NAME = "immich/MediaObserverV1"
|
||||
|
||||
const val WORKER_DATA_TASK_TYPE = "taskType"
|
||||
|
||||
const val SHARED_PREF_NAME = "Immich::Background"
|
||||
const val SHARED_PREF_BACKUP_ENABLED = "Background::backup::enabled"
|
||||
const val SHARED_PREF_CALLBACK_HANDLE = "Background::backup::callbackHandle"
|
||||
|
||||
private fun updateUploadEnabled(context: Context, enabled: Boolean) {
|
||||
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE).edit {
|
||||
putBoolean(SHARED_PREF_BACKUP_ENABLED, enabled)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCallbackHandle(context: Context, callbackHandle: Long) {
|
||||
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE).edit {
|
||||
putLong(SHARED_PREF_CALLBACK_HANDLE, callbackHandle)
|
||||
}
|
||||
}
|
||||
|
||||
fun enqueueMediaObserver(ctx: Context) {
|
||||
val constraints = Constraints.Builder()
|
||||
.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)
|
||||
.setTriggerContentUpdateDelay(30, TimeUnit.SECONDS)
|
||||
.setTriggerContentMaxDelay(3, TimeUnit.MINUTES)
|
||||
.setTriggerContentUpdateDelay(5, TimeUnit.SECONDS)
|
||||
.setTriggerContentMaxDelay(1, TimeUnit.MINUTES)
|
||||
.build()
|
||||
|
||||
val work = OneTimeWorkRequest.Builder(MediaObserver::class.java)
|
||||
@@ -48,13 +74,15 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
||||
Log.i(TAG, "Enqueued media observer worker with name: $OBSERVER_WORKER_NAME")
|
||||
}
|
||||
|
||||
fun enqueueBackgroundWorker(ctx: Context) {
|
||||
fun enqueueBackgroundWorker(ctx: Context, taskType: BackgroundTaskType) {
|
||||
val constraints = Constraints.Builder().setRequiresBatteryNotLow(true).build()
|
||||
|
||||
val data = Data.Builder()
|
||||
data.putInt(WORKER_DATA_TASK_TYPE, taskType.ordinal)
|
||||
val work = OneTimeWorkRequest.Builder(BackgroundWorker::class.java)
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
|
||||
.build()
|
||||
.setInputData(data.build()).build()
|
||||
WorkManager.getInstance(ctx)
|
||||
.enqueueUniqueWork(BACKGROUND_WORKER_NAME, ExistingWorkPolicy.REPLACE, work)
|
||||
|
||||
|
||||
@@ -6,17 +6,29 @@ import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
|
||||
class MediaObserver(context: Context, params: WorkerParameters) : Worker(context, params) {
|
||||
private val ctx: Context = context.applicationContext
|
||||
private val ctx: Context = context.applicationContext
|
||||
|
||||
override fun doWork(): Result {
|
||||
Log.i("MediaObserver", "Content change detected, starting background worker")
|
||||
// Re-enqueue itself to listen for future changes
|
||||
BackgroundWorkerApiImpl.enqueueMediaObserver(ctx)
|
||||
override fun doWork(): Result {
|
||||
Log.i("MediaObserver", "Content change detected, starting background worker")
|
||||
|
||||
// Enqueue backup worker only if there are new media changes
|
||||
if (triggeredContentUris.isNotEmpty()) {
|
||||
BackgroundWorkerApiImpl.enqueueBackgroundWorker(ctx)
|
||||
// Enqueue backup worker only if there are new media changes
|
||||
if (triggeredContentUris.isNotEmpty()) {
|
||||
val type =
|
||||
if (isBackupEnabled(ctx)) BackgroundTaskType.UPLOAD else BackgroundTaskType.LOCAL_SYNC
|
||||
BackgroundWorkerApiImpl.enqueueBackgroundWorker(ctx, type)
|
||||
}
|
||||
|
||||
// Re-enqueue itself to listen for future changes
|
||||
BackgroundWorkerApiImpl.enqueueMediaObserver(ctx)
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private fun isBackupEnabled(context: Context): Boolean {
|
||||
val prefs =
|
||||
context.getSharedPreferences(
|
||||
BackgroundWorkerApiImpl.SHARED_PREF_NAME,
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
return prefs.getBoolean(BackgroundWorkerApiImpl.SHARED_PREF_BACKUP_ENABLED, false)
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- connectivity_plus (0.0.1):
|
||||
- Flutter
|
||||
- cupertino_http (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- device_info_plus (0.0.1):
|
||||
- Flutter
|
||||
- DKImagePickerController/Core (4.3.9):
|
||||
@@ -77,6 +80,8 @@ PODS:
|
||||
- Flutter
|
||||
- network_info_plus (0.0.1):
|
||||
- Flutter
|
||||
- objective_c (0.0.1):
|
||||
- Flutter
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- path_provider_foundation (0.0.1):
|
||||
@@ -136,6 +141,7 @@ DEPENDENCIES:
|
||||
- background_downloader (from `.symlinks/plugins/background_downloader/ios`)
|
||||
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- cupertino_http (from `.symlinks/plugins/cupertino_http/darwin`)
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
@@ -154,6 +160,7 @@ DEPENDENCIES:
|
||||
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
|
||||
- native_video_player (from `.symlinks/plugins/native_video_player/ios`)
|
||||
- network_info_plus (from `.symlinks/plugins/network_info_plus/ios`)
|
||||
- objective_c (from `.symlinks/plugins/objective_c/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
@@ -184,6 +191,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/bonsoir_darwin/darwin"
|
||||
connectivity_plus:
|
||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||
cupertino_http:
|
||||
:path: ".symlinks/plugins/cupertino_http/darwin"
|
||||
device_info_plus:
|
||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||
file_picker:
|
||||
@@ -220,6 +229,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/native_video_player/ios"
|
||||
network_info_plus:
|
||||
:path: ".symlinks/plugins/network_info_plus/ios"
|
||||
objective_c:
|
||||
:path: ".symlinks/plugins/objective_c/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_foundation:
|
||||
@@ -249,6 +260,7 @@ SPEC CHECKSUMS:
|
||||
background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad
|
||||
bonsoir_darwin: 29c7ccf356646118844721f36e1de4b61f6cbd0e
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
|
||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||
@@ -270,6 +282,7 @@ SPEC CHECKSUMS:
|
||||
maplibre_gl: 3c924e44725147b03dda33430ad216005b40555f
|
||||
native_video_player: b65c58951ede2f93d103a25366bdebca95081265
|
||||
network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc
|
||||
objective_c: 89e720c30d716b036faf9c9684022048eee1eee2
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
|
||||
@@ -24,7 +24,7 @@ import UIKit
|
||||
BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!)
|
||||
|
||||
BackgroundServicePlugin.registerBackgroundProcessing()
|
||||
BackgroundWorkerApiImpl.registerBackgroundWorkers()
|
||||
BackgroundWorkerApiImpl.registerBackgroundProcessing()
|
||||
|
||||
BackgroundServicePlugin.setPluginRegistrantCallback { registry in
|
||||
if !registry.hasPlugin("org.cocoapods.path-provider-foundation") {
|
||||
|
||||
@@ -73,8 +73,9 @@ class BackgroundWorkerPigeonCodec: FlutterStandardMessageCodec, @unchecked Senda
|
||||
|
||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||
protocol BackgroundWorkerFgHostApi {
|
||||
func enable() throws
|
||||
func disable() throws
|
||||
func enableSyncWorker() throws
|
||||
func enableUploadWorker(callbackHandle: Int64) throws
|
||||
func disableUploadWorker() throws
|
||||
}
|
||||
|
||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||
@@ -83,38 +84,52 @@ class BackgroundWorkerFgHostApiSetup {
|
||||
/// Sets up an instance of `BackgroundWorkerFgHostApi` to handle messages through the `binaryMessenger`.
|
||||
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: BackgroundWorkerFgHostApi?, messageChannelSuffix: String = "") {
|
||||
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
|
||||
let enableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
let enableSyncWorkerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableSyncWorker\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
enableChannel.setMessageHandler { _, reply in
|
||||
enableSyncWorkerChannel.setMessageHandler { _, reply in
|
||||
do {
|
||||
try api.enable()
|
||||
try api.enableSyncWorker()
|
||||
reply(wrapResult(nil))
|
||||
} catch {
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
enableChannel.setMessageHandler(nil)
|
||||
enableSyncWorkerChannel.setMessageHandler(nil)
|
||||
}
|
||||
let disableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
let enableUploadWorkerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
disableChannel.setMessageHandler { _, reply in
|
||||
enableUploadWorkerChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let callbackHandleArg = args[0] as! Int64
|
||||
do {
|
||||
try api.disable()
|
||||
try api.enableUploadWorker(callbackHandle: callbackHandleArg)
|
||||
reply(wrapResult(nil))
|
||||
} catch {
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
disableChannel.setMessageHandler(nil)
|
||||
enableUploadWorkerChannel.setMessageHandler(nil)
|
||||
}
|
||||
let disableUploadWorkerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disableUploadWorker\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
disableUploadWorkerChannel.setMessageHandler { _, reply in
|
||||
do {
|
||||
try api.disableUploadWorker()
|
||||
reply(wrapResult(nil))
|
||||
} catch {
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
disableUploadWorkerChannel.setMessageHandler(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||
protocol BackgroundWorkerBgHostApi {
|
||||
func onInitialized() throws
|
||||
func close() throws
|
||||
}
|
||||
|
||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||
@@ -136,23 +151,11 @@ class BackgroundWorkerBgHostApiSetup {
|
||||
} else {
|
||||
onInitializedChannel.setMessageHandler(nil)
|
||||
}
|
||||
let closeChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.close\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
closeChannel.setMessageHandler { _, reply in
|
||||
do {
|
||||
try api.close()
|
||||
reply(wrapResult(nil))
|
||||
} catch {
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
closeChannel.setMessageHandler(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift.
|
||||
protocol BackgroundWorkerFlutterApiProtocol {
|
||||
func onLocalSync(maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result<Void, PigeonError>) -> Void)
|
||||
func onIosUpload(isRefresh isRefreshArg: Bool, maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result<Void, PigeonError>) -> Void)
|
||||
func onAndroidUpload(completion: @escaping (Result<Void, PigeonError>) -> Void)
|
||||
func cancel(completion: @escaping (Result<Void, PigeonError>) -> Void)
|
||||
@@ -167,6 +170,24 @@ class BackgroundWorkerFlutterApi: BackgroundWorkerFlutterApiProtocol {
|
||||
var codec: BackgroundWorkerPigeonCodec {
|
||||
return BackgroundWorkerPigeonCodec.shared
|
||||
}
|
||||
func onLocalSync(maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result<Void, PigeonError>) -> Void) {
|
||||
let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onLocalSync\(messageChannelSuffix)"
|
||||
let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec)
|
||||
channel.sendMessage([maxSecondsArg] as [Any?]) { response in
|
||||
guard let listResponse = response as? [Any?] else {
|
||||
completion(.failure(createConnectionError(withChannelName: channelName)))
|
||||
return
|
||||
}
|
||||
if listResponse.count > 1 {
|
||||
let code: String = listResponse[0] as! String
|
||||
let message: String? = nilOrValue(listResponse[1])
|
||||
let details: String? = nilOrValue(listResponse[2])
|
||||
completion(.failure(PigeonError(code: code, message: message, details: details)))
|
||||
} else {
|
||||
completion(.success(()))
|
||||
}
|
||||
}
|
||||
}
|
||||
func onIosUpload(isRefresh isRefreshArg: Bool, maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result<Void, PigeonError>) -> Void) {
|
||||
let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload\(messageChannelSuffix)"
|
||||
let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import BackgroundTasks
|
||||
import Flutter
|
||||
|
||||
enum BackgroundTaskType { case refresh, processing }
|
||||
enum BackgroundTaskType { case localSync, refreshUpload, processingUpload }
|
||||
|
||||
/*
|
||||
* DEBUG: Testing Background Tasks in Xcode
|
||||
@@ -9,6 +9,10 @@ enum BackgroundTaskType { case refresh, processing }
|
||||
* To test background task functionality during development:
|
||||
* 1. Pause the application in Xcode debugger
|
||||
* 2. In the debugger console, enter one of the following commands:
|
||||
|
||||
## For local sync (short-running sync):
|
||||
|
||||
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"app.alextran.immich.background.localSync"]
|
||||
|
||||
## For background refresh (short-running sync):
|
||||
|
||||
@@ -20,6 +24,8 @@ enum BackgroundTaskType { case refresh, processing }
|
||||
|
||||
* To simulate task expiration (useful for testing expiration handlers):
|
||||
|
||||
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"app.alextran.immich.background.localSync"]
|
||||
|
||||
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"app.alextran.immich.background.refreshUpload"]
|
||||
|
||||
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"app.alextran.immich.background.processingUpload"]
|
||||
@@ -80,10 +86,28 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
|
||||
* starts the engine, and sets up a timeout timer if specified.
|
||||
*/
|
||||
func run() {
|
||||
// Retrieve the callback handle stored by the main Flutter app
|
||||
// This handle points to the Flutter function that should be executed in the background
|
||||
let callbackHandle = Int64(UserDefaults.standard.string(
|
||||
forKey: BackgroundWorkerApiImpl.backgroundUploadCallbackHandleKey) ?? "0") ?? 0
|
||||
|
||||
if callbackHandle == 0 {
|
||||
// Without a valid callback handle, we cannot start the Flutter background execution
|
||||
complete(success: false)
|
||||
return
|
||||
}
|
||||
|
||||
// Use the callback handle to retrieve the actual Flutter callback information
|
||||
guard let callback = FlutterCallbackCache.lookupCallbackInformation(callbackHandle) else {
|
||||
// The callback handle is invalid or the callback was not found
|
||||
complete(success: false)
|
||||
return
|
||||
}
|
||||
|
||||
// Start the Flutter engine with the specified callback as the entry point
|
||||
let isRunning = engine.run(
|
||||
withEntrypoint: "backgroundSyncNativeEntrypoint",
|
||||
libraryURI: "package:immich_mobile/domain/services/background_worker.service.dart"
|
||||
withEntrypoint: callback.callbackName,
|
||||
libraryURI: callback.callbackLibraryPath
|
||||
)
|
||||
|
||||
// Verify that the Flutter engine started successfully
|
||||
@@ -103,7 +127,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
|
||||
if maxSeconds != nil {
|
||||
// Schedule a timer to cancel the task after the specified timeout period
|
||||
Timer.scheduledTimer(withTimeInterval: TimeInterval(maxSeconds!), repeats: false) { _ in
|
||||
self.close()
|
||||
self.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,9 +138,17 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
|
||||
* This method acts as a bridge between the native iOS background task system and Flutter.
|
||||
*/
|
||||
func onInitialized() throws {
|
||||
flutterApi?.onIosUpload(isRefresh: self.taskType == .refresh, maxSeconds: maxSeconds.map { Int64($0) }, completion: { result in
|
||||
self.handleHostResult(result: result)
|
||||
})
|
||||
switch self.taskType {
|
||||
case .refreshUpload, .processingUpload:
|
||||
flutterApi?.onIosUpload(isRefresh: self.taskType == .refreshUpload,
|
||||
maxSeconds: maxSeconds.map { Int64($0) }, completion: { result in
|
||||
self.handleHostResult(result: result)
|
||||
})
|
||||
case .localSync:
|
||||
flutterApi?.onLocalSync(maxSeconds: maxSeconds.map { Int64($0) }, completion: { result in
|
||||
self.handleHostResult(result: result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,7 +156,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
|
||||
* Sends a cancel signal to the Flutter side and sets up a fallback timer to ensure
|
||||
* the completion handler is eventually called even if Flutter doesn't respond.
|
||||
*/
|
||||
func close() {
|
||||
func cancel() {
|
||||
if isComplete {
|
||||
return
|
||||
}
|
||||
@@ -150,7 +182,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
|
||||
private func handleHostResult(result: Result<Void, PigeonError>) {
|
||||
switch result {
|
||||
case .success(): self.complete(success: true)
|
||||
case .failure(_): self.close()
|
||||
case .failure(_): self.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,10 +195,6 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
|
||||
* - Parameter success: Indicates whether the background task completed successfully
|
||||
*/
|
||||
private func complete(success: Bool) {
|
||||
if(isComplete) {
|
||||
return
|
||||
}
|
||||
|
||||
isComplete = true
|
||||
engine.destroyContext()
|
||||
completionHandler(success)
|
||||
|
||||
@@ -1,40 +1,84 @@
|
||||
import BackgroundTasks
|
||||
|
||||
class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||
|
||||
func enable() throws {
|
||||
BackgroundWorkerApiImpl.scheduleRefreshWorker()
|
||||
BackgroundWorkerApiImpl.scheduleProcessingWorker()
|
||||
print("BackgroundUploadImpl:enbale Background worker scheduled")
|
||||
func enableSyncWorker() throws {
|
||||
BackgroundWorkerApiImpl.scheduleLocalSync()
|
||||
print("BackgroundUploadImpl:enableSyncWorker Local Sync worker scheduled")
|
||||
}
|
||||
|
||||
func disable() throws {
|
||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.refreshTaskID);
|
||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.processingTaskID);
|
||||
print("BackgroundUploadImpl:disableUploadWorker Disabled background workers")
|
||||
func enableUploadWorker(callbackHandle: Int64) throws {
|
||||
BackgroundWorkerApiImpl.updateUploadEnabled(true)
|
||||
// Store the callback handle for later use when starting background Flutter isolates
|
||||
BackgroundWorkerApiImpl.updateUploadCallbackHandle(callbackHandle)
|
||||
|
||||
BackgroundWorkerApiImpl.scheduleRefreshUpload()
|
||||
BackgroundWorkerApiImpl.scheduleProcessingUpload()
|
||||
print("BackgroundUploadImpl:enableUploadWorker Scheduled background upload tasks")
|
||||
}
|
||||
|
||||
private static let refreshTaskID = "app.alextran.immich.background.refreshUpload"
|
||||
private static let processingTaskID = "app.alextran.immich.background.processingUpload"
|
||||
func disableUploadWorker() throws {
|
||||
BackgroundWorkerApiImpl.updateUploadEnabled(false)
|
||||
BackgroundWorkerApiImpl.cancelUploadTasks()
|
||||
print("BackgroundUploadImpl:disableUploadWorker Disabled background upload tasks")
|
||||
}
|
||||
|
||||
public static let backgroundUploadEnabledKey = "immich:background:backup:enabled"
|
||||
public static let backgroundUploadCallbackHandleKey = "immich:background:backup:callbackHandle"
|
||||
|
||||
private static let localSyncTaskID = "app.alextran.immich.background.localSync"
|
||||
private static let refreshUploadTaskID = "app.alextran.immich.background.refreshUpload"
|
||||
private static let processingUploadTaskID = "app.alextran.immich.background.processingUpload"
|
||||
|
||||
public static func registerBackgroundWorkers() {
|
||||
private static func updateUploadEnabled(_ isEnabled: Bool) {
|
||||
return UserDefaults.standard.set(isEnabled, forKey: BackgroundWorkerApiImpl.backgroundUploadEnabledKey)
|
||||
}
|
||||
|
||||
private static func updateUploadCallbackHandle(_ callbackHandle: Int64) {
|
||||
return UserDefaults.standard.set(String(callbackHandle), forKey: BackgroundWorkerApiImpl.backgroundUploadCallbackHandleKey)
|
||||
}
|
||||
|
||||
private static func cancelUploadTasks() {
|
||||
BackgroundWorkerApiImpl.updateUploadEnabled(false)
|
||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: refreshUploadTaskID);
|
||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: processingUploadTaskID);
|
||||
}
|
||||
|
||||
public static func registerBackgroundProcessing() {
|
||||
BGTaskScheduler.shared.register(
|
||||
forTaskWithIdentifier: processingTaskID, using: nil) { task in
|
||||
forTaskWithIdentifier: processingUploadTaskID, using: nil) { task in
|
||||
if task is BGProcessingTask {
|
||||
handleBackgroundProcessing(task: task as! BGProcessingTask)
|
||||
}
|
||||
}
|
||||
|
||||
BGTaskScheduler.shared.register(
|
||||
forTaskWithIdentifier: refreshTaskID, using: nil) { task in
|
||||
forTaskWithIdentifier: refreshUploadTaskID, using: nil) { task in
|
||||
if task is BGAppRefreshTask {
|
||||
handleBackgroundRefresh(task: task as! BGAppRefreshTask)
|
||||
handleBackgroundRefresh(task: task as! BGAppRefreshTask, taskType: .refreshUpload)
|
||||
}
|
||||
}
|
||||
|
||||
BGTaskScheduler.shared.register(
|
||||
forTaskWithIdentifier: localSyncTaskID, using: nil) { task in
|
||||
if task is BGAppRefreshTask {
|
||||
handleBackgroundRefresh(task: task as! BGAppRefreshTask, taskType: .localSync)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func scheduleLocalSync() {
|
||||
let backgroundRefresh = BGAppRefreshTaskRequest(identifier: localSyncTaskID)
|
||||
backgroundRefresh.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60) // 5 mins
|
||||
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(backgroundRefresh)
|
||||
} catch {
|
||||
print("Could not schedule the local sync task \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func scheduleRefreshWorker() {
|
||||
let backgroundRefresh = BGAppRefreshTaskRequest(identifier: refreshTaskID)
|
||||
private static func scheduleRefreshUpload() {
|
||||
let backgroundRefresh = BGAppRefreshTaskRequest(identifier: refreshUploadTaskID)
|
||||
backgroundRefresh.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60) // 5 mins
|
||||
|
||||
do {
|
||||
@@ -44,8 +88,8 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||
}
|
||||
}
|
||||
|
||||
private static func scheduleProcessingWorker() {
|
||||
let backgroundProcessing = BGProcessingTaskRequest(identifier: processingTaskID)
|
||||
private static func scheduleProcessingUpload() {
|
||||
let backgroundProcessing = BGProcessingTaskRequest(identifier: processingUploadTaskID)
|
||||
|
||||
backgroundProcessing.requiresNetworkConnectivity = true
|
||||
backgroundProcessing.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 mins
|
||||
@@ -57,16 +101,16 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleBackgroundRefresh(task: BGAppRefreshTask) {
|
||||
scheduleRefreshWorker()
|
||||
// Restrict the refresh task to run only for a maximum of (maxSeconds) seconds
|
||||
runBackgroundWorker(task: task, taskType: .refresh, maxSeconds: 20)
|
||||
private static func handleBackgroundRefresh(task: BGAppRefreshTask, taskType: BackgroundTaskType) {
|
||||
scheduleRefreshUpload()
|
||||
// Restrict the refresh task to run only for a maximum of 20 seconds
|
||||
runBackgroundWorker(task: task, taskType: taskType, maxSeconds: 20)
|
||||
}
|
||||
|
||||
private static func handleBackgroundProcessing(task: BGProcessingTask) {
|
||||
scheduleProcessingWorker()
|
||||
scheduleProcessingUpload()
|
||||
// There are no restrictions for processing tasks. Although, the OS could signal expiration at any time
|
||||
runBackgroundWorker(task: task, taskType: .processing, maxSeconds: nil)
|
||||
runBackgroundWorker(task: task, taskType: .processingUpload, maxSeconds: nil)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,7 +134,7 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||
|
||||
task.expirationHandler = {
|
||||
DispatchQueue.main.async {
|
||||
backgroundWorker.close()
|
||||
backgroundWorker.cancel()
|
||||
}
|
||||
isSuccess = false
|
||||
|
||||
|
||||
@@ -1,189 +1,190 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>AppGroupId</key>
|
||||
<string>$(CUSTOM_GROUP_ID)</string>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>app.alextran.immich.background.refreshUpload</string>
|
||||
<string>app.alextran.immich.background.processingUpload</string>
|
||||
<string>app.alextran.immich.backgroundFetch</string>
|
||||
<string>app.alextran.immich.backgroundProcessing</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>${PRODUCT_NAME}</string>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>ShareHandler</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>public.file-url</string>
|
||||
<string>public.image</string>
|
||||
<string>public.text</string>
|
||||
<string>public.movie</string>
|
||||
<string>public.url</string>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
<string>en</string>
|
||||
<string>ar</string>
|
||||
<string>ca</string>
|
||||
<string>cs</string>
|
||||
<string>da</string>
|
||||
<string>de</string>
|
||||
<string>es</string>
|
||||
<string>fi</string>
|
||||
<string>fr</string>
|
||||
<string>he</string>
|
||||
<string>hi</string>
|
||||
<string>hu</string>
|
||||
<string>it</string>
|
||||
<string>ja</string>
|
||||
<string>ko</string>
|
||||
<string>lv</string>
|
||||
<string>mn</string>
|
||||
<string>nb</string>
|
||||
<string>nl</string>
|
||||
<string>pl</string>
|
||||
<string>pt</string>
|
||||
<string>ro</string>
|
||||
<string>ru</string>
|
||||
<string>sk</string>
|
||||
<string>sl</string>
|
||||
<string>sr</string>
|
||||
<string>sv</string>
|
||||
<string>th</string>
|
||||
<string>uk</string>
|
||||
<string>vi</string>
|
||||
<string>zh</string>
|
||||
</array>
|
||||
<key>CFBundleName</key>
|
||||
<string>immich_mobile</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.140.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>Share Extension</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>Deep Link</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>immich</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>219</string>
|
||||
<key>FLTEnableImpeller</key>
|
||||
<true/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>https</string>
|
||||
</array>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<string>No</string>
|
||||
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_googlecast._tcp</string>
|
||||
<string>_CC1AD845._googlecast._tcp</string>
|
||||
</array>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>We need to access the camera to let you take beautiful video using this app</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>We need to use FaceID to allow access to your locked folder</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>We need local network permission to connect to the local server using IP address and
|
||||
<dict>
|
||||
<key>AppGroupId</key>
|
||||
<string>$(CUSTOM_GROUP_ID)</string>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>app.alextran.immich.background.localSync</string>
|
||||
<string>app.alextran.immich.background.refreshUpload</string>
|
||||
<string>app.alextran.immich.background.processingUpload</string>
|
||||
<string>app.alextran.immich.backgroundFetch</string>
|
||||
<string>app.alextran.immich.backgroundProcessing</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true />
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>${PRODUCT_NAME}</string>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>ShareHandler</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>public.file-url</string>
|
||||
<string>public.image</string>
|
||||
<string>public.text</string>
|
||||
<string>public.movie</string>
|
||||
<string>public.url</string>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
<string>en</string>
|
||||
<string>ar</string>
|
||||
<string>ca</string>
|
||||
<string>cs</string>
|
||||
<string>da</string>
|
||||
<string>de</string>
|
||||
<string>es</string>
|
||||
<string>fi</string>
|
||||
<string>fr</string>
|
||||
<string>he</string>
|
||||
<string>hi</string>
|
||||
<string>hu</string>
|
||||
<string>it</string>
|
||||
<string>ja</string>
|
||||
<string>ko</string>
|
||||
<string>lv</string>
|
||||
<string>mn</string>
|
||||
<string>nb</string>
|
||||
<string>nl</string>
|
||||
<string>pl</string>
|
||||
<string>pt</string>
|
||||
<string>ro</string>
|
||||
<string>ru</string>
|
||||
<string>sk</string>
|
||||
<string>sl</string>
|
||||
<string>sr</string>
|
||||
<string>sv</string>
|
||||
<string>th</string>
|
||||
<string>uk</string>
|
||||
<string>vi</string>
|
||||
<string>zh</string>
|
||||
</array>
|
||||
<key>CFBundleName</key>
|
||||
<string>immich_mobile</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.140.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>Share Extension</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>Deep Link</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>immich</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>219</string>
|
||||
<key>FLTEnableImpeller</key>
|
||||
<true />
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false />
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>https</string>
|
||||
</array>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true />
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<string>No</string>
|
||||
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
||||
<true />
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true />
|
||||
</dict>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_googlecast._tcp</string>
|
||||
<string>_CC1AD845._googlecast._tcp</string>
|
||||
</array>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>We need to access the camera to let you take beautiful video using this app</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>We need to use FaceID to allow access to your locked folder</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>We need local network permission to connect to the local server using IP address and
|
||||
allow the casting feature to work</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
|
||||
<key>NSLocationUsageDescription</key>
|
||||
<string>We require this permission to access the local WiFi name</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>We require this permission to access the local WiFi name</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>We need to access the microphone to let you take beautiful video using this app</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>We need to manage backup your photos album</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>We need to manage backup your photos album</string>
|
||||
<key>NSUserActivityTypes</key>
|
||||
<array>
|
||||
<string>INSendMessageIntent</string>
|
||||
</array>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
<key>io.flutter.embedded_views_preview</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
|
||||
<key>NSLocationUsageDescription</key>
|
||||
<string>We require this permission to access the local WiFi name</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>We require this permission to access the local WiFi name</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>We need to access the microphone to let you take beautiful video using this app</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>We need to manage backup your photos album</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>We need to manage backup your photos album</string>
|
||||
<key>NSUserActivityTypes</key>
|
||||
<array>
|
||||
<string>INSendMessageIntent</string>
|
||||
</array>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true />
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false />
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true />
|
||||
<key>io.flutter.embedded_views_preview</key>
|
||||
<true />
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -14,7 +14,6 @@ import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/services/auth.service.dart';
|
||||
import 'package:immich_mobile/services/localization.service.dart';
|
||||
@@ -30,9 +29,13 @@ class BackgroundWorkerFgService {
|
||||
const BackgroundWorkerFgService(this._foregroundHostApi);
|
||||
|
||||
// TODO: Move this call to native side once old timeline is removed
|
||||
Future<void> enable() => _foregroundHostApi.enable();
|
||||
Future<void> enableSyncService() => _foregroundHostApi.enableSyncWorker();
|
||||
|
||||
Future<void> disable() => _foregroundHostApi.disable();
|
||||
Future<void> enableUploadService() => _foregroundHostApi.enableUploadWorker(
|
||||
PluginUtilities.getCallbackHandle(_backgroundSyncNativeEntrypoint)!.toRawHandle(),
|
||||
);
|
||||
|
||||
Future<void> disableUploadService() => _foregroundHostApi.disableUploadWorker();
|
||||
}
|
||||
|
||||
class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
@@ -41,7 +44,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
final Drift _drift;
|
||||
final DriftLogger _driftLogger;
|
||||
final BackgroundWorkerBgHostApi _backgroundHostApi;
|
||||
final Logger _logger = Logger('BackgroundWorkerBgService');
|
||||
final Logger _logger = Logger('BackgroundUploadBgService');
|
||||
|
||||
bool _isCleanedUp = false;
|
||||
|
||||
@@ -63,85 +66,92 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
bool get _isBackupEnabled => _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
|
||||
|
||||
Future<void> init() async {
|
||||
try {
|
||||
await loadTranslations();
|
||||
HttpSSLOptions.apply(applyNative: false);
|
||||
await _ref.read(authServiceProvider).setOpenApiServiceEndpoint();
|
||||
await loadTranslations();
|
||||
HttpSSLOptions.apply(applyNative: false);
|
||||
await _ref.read(authServiceProvider).setOpenApiServiceEndpoint();
|
||||
|
||||
// Initialize the file downloader
|
||||
await FileDownloader().configure(
|
||||
globalConfig: [
|
||||
// maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3
|
||||
(Config.holdingQueue, (6, 6, 3)),
|
||||
// On Android, if files are larger than 256MB, run in foreground service
|
||||
(Config.runInForegroundIfFileLargerThan, 256),
|
||||
],
|
||||
);
|
||||
await FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false);
|
||||
await FileDownloader().trackTasks();
|
||||
configureFileDownloaderNotifications();
|
||||
// Initialize the file downloader
|
||||
await FileDownloader().configure(
|
||||
globalConfig: [
|
||||
// maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3
|
||||
(Config.holdingQueue, (6, 6, 3)),
|
||||
// On Android, if files are larger than 256MB, run in foreground service
|
||||
(Config.runInForegroundIfFileLargerThan, 256),
|
||||
],
|
||||
);
|
||||
await FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false);
|
||||
await FileDownloader().trackTasks();
|
||||
configureFileDownloaderNotifications();
|
||||
|
||||
await _ref.read(fileMediaRepositoryProvider).enableBackgroundAccess();
|
||||
|
||||
// Notify the host that the background worker service has been initialized and is ready to use
|
||||
_backgroundHostApi.onInitialized();
|
||||
} catch (error, stack) {
|
||||
_logger.severe("Failed to initialize background worker", error, stack);
|
||||
_backgroundHostApi.close();
|
||||
}
|
||||
// Notify the host that the background upload service has been initialized and is ready to use
|
||||
await _backgroundHostApi.onInitialized();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onLocalSync(int? maxSeconds) async {
|
||||
_logger.info('Local background syncing started');
|
||||
final sw = Stopwatch()..start();
|
||||
|
||||
final timeout = maxSeconds != null ? Duration(seconds: maxSeconds) : null;
|
||||
await _syncAssets(hashTimeout: timeout, syncRemote: false);
|
||||
|
||||
sw.stop();
|
||||
_logger.info("Local sync completed in ${sw.elapsed.inSeconds}s");
|
||||
}
|
||||
|
||||
/* We do the following on Android upload
|
||||
* - Sync local assets
|
||||
* - Hash local assets 3 / 6 minutes
|
||||
* - Sync remote assets
|
||||
* - Check and requeue upload tasks
|
||||
*/
|
||||
@override
|
||||
Future<void> onAndroidUpload() async {
|
||||
try {
|
||||
_logger.info('Android background processing started');
|
||||
final sw = Stopwatch()..start();
|
||||
_logger.info('Android background processing started');
|
||||
final sw = Stopwatch()..start();
|
||||
|
||||
await _syncAssets(hashTimeout: Duration(minutes: _isBackupEnabled ? 3 : 6));
|
||||
await _handleBackup(processBulk: false);
|
||||
await _syncAssets(hashTimeout: Duration(minutes: _isBackupEnabled ? 3 : 6));
|
||||
await _handleBackup(processBulk: false);
|
||||
|
||||
sw.stop();
|
||||
_logger.info("Android background processing completed in ${sw.elapsed.inSeconds}s");
|
||||
} catch (error, stack) {
|
||||
_logger.severe("Failed to complete Android background processing", error, stack);
|
||||
} finally {
|
||||
await _cleanup();
|
||||
}
|
||||
await _cleanup();
|
||||
|
||||
sw.stop();
|
||||
_logger.info("Android background processing completed in ${sw.elapsed.inSeconds}s");
|
||||
}
|
||||
|
||||
/* We do the following on background upload
|
||||
* - Sync local assets
|
||||
* - Hash local assets
|
||||
* - Sync remote assets
|
||||
* - Check and requeue upload tasks
|
||||
*
|
||||
* The native side will not send the maxSeconds value for processing tasks
|
||||
*/
|
||||
@override
|
||||
Future<void> onIosUpload(bool isRefresh, int? maxSeconds) async {
|
||||
try {
|
||||
_logger.info('iOS background upload started with maxSeconds: ${maxSeconds}s');
|
||||
final sw = Stopwatch()..start();
|
||||
_logger.info('iOS background upload started with maxSeconds: ${maxSeconds}s');
|
||||
final sw = Stopwatch()..start();
|
||||
|
||||
final timeout = isRefresh ? const Duration(seconds: 5) : Duration(minutes: _isBackupEnabled ? 3 : 6);
|
||||
await _syncAssets(hashTimeout: timeout);
|
||||
final timeout = isRefresh ? const Duration(seconds: 5) : Duration(minutes: _isBackupEnabled ? 3 : 6);
|
||||
await _syncAssets(hashTimeout: timeout);
|
||||
|
||||
final backupFuture = _handleBackup();
|
||||
if (maxSeconds != null) {
|
||||
await backupFuture.timeout(Duration(seconds: maxSeconds - 1), onTimeout: () {});
|
||||
} else {
|
||||
await backupFuture;
|
||||
}
|
||||
|
||||
sw.stop();
|
||||
_logger.info("iOS background upload completed in ${sw.elapsed.inSeconds}s");
|
||||
} catch (error, stack) {
|
||||
_logger.severe("Failed to complete iOS background upload", error, stack);
|
||||
} finally {
|
||||
await _cleanup();
|
||||
final backupFuture = _handleBackup();
|
||||
if (maxSeconds != null) {
|
||||
await backupFuture.timeout(Duration(seconds: maxSeconds - 1), onTimeout: () {});
|
||||
} else {
|
||||
await backupFuture;
|
||||
}
|
||||
|
||||
await _cleanup();
|
||||
|
||||
sw.stop();
|
||||
_logger.info("iOS background upload completed in ${sw.elapsed.inSeconds}s");
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cancel() async {
|
||||
_logger.warning("Background worker cancelled");
|
||||
try {
|
||||
await _cleanup();
|
||||
} catch (error, stack) {
|
||||
debugPrint('Failed to cleanup background worker: $error with stack: $stack');
|
||||
}
|
||||
_logger.warning("Background upload cancelled");
|
||||
await _cleanup();
|
||||
}
|
||||
|
||||
Future<void> _cleanup() async {
|
||||
@@ -149,21 +159,13 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
_isCleanedUp = true;
|
||||
_logger.info("Cleaning up background worker");
|
||||
await _ref.read(backgroundSyncProvider).cancel();
|
||||
await _ref.read(backgroundSyncProvider).cancelLocal();
|
||||
if (_isar.isOpen) {
|
||||
await _isar.close();
|
||||
}
|
||||
await _drift.close();
|
||||
await _driftLogger.close();
|
||||
_ref.dispose();
|
||||
debugPrint("Background worker cleaned up");
|
||||
} catch (error, stack) {
|
||||
debugPrint('Failed to cleanup background worker: $error with stack: $stack');
|
||||
}
|
||||
_isCleanedUp = true;
|
||||
await _ref.read(backgroundSyncProvider).cancel();
|
||||
await _ref.read(backgroundSyncProvider).cancelLocal();
|
||||
await _isar.close();
|
||||
await _drift.close();
|
||||
await _driftLogger.close();
|
||||
_ref.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleBackup({bool processBulk = true}) async {
|
||||
@@ -188,7 +190,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncAssets({Duration? hashTimeout}) async {
|
||||
Future<void> _syncAssets({Duration? hashTimeout, bool syncRemote = true}) async {
|
||||
final futures = <Future<void>>[];
|
||||
|
||||
final localSyncFuture = _ref.read(backgroundSyncProvider).syncLocal().then((_) async {
|
||||
@@ -210,16 +212,17 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
});
|
||||
|
||||
futures.add(localSyncFuture);
|
||||
futures.add(_ref.read(backgroundSyncProvider).syncRemote());
|
||||
if (syncRemote) {
|
||||
final remoteSyncFuture = _ref.read(backgroundSyncProvider).syncRemote();
|
||||
futures.add(remoteSyncFuture);
|
||||
}
|
||||
|
||||
await Future.wait(futures);
|
||||
}
|
||||
}
|
||||
|
||||
/// Native entry invoked from the background worker. If renaming or moving this to a different
|
||||
/// library, make sure to update the entry points and URI in native workers as well
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> backgroundSyncNativeEntrypoint() async {
|
||||
Future<void> _backgroundSyncNativeEntrypoint() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
DartPluginRegistrant.ensureInitialized();
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
@@ -36,7 +35,6 @@ class HashService {
|
||||
bool get isCancelled => _cancelChecker?.call() ?? false;
|
||||
|
||||
Future<void> hashAssets() async {
|
||||
_log.info("Starting hashing of assets");
|
||||
final Stopwatch stopwatch = Stopwatch()..start();
|
||||
// Sorted by backupSelection followed by isCloud
|
||||
final localAlbums = await _localAlbumRepository.getAll(
|
||||
@@ -51,7 +49,7 @@ class HashService {
|
||||
|
||||
final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
|
||||
if (assetsToHash.isNotEmpty) {
|
||||
await _hashAssets(album, assetsToHash);
|
||||
await _hashAssets(assetsToHash);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +60,7 @@ class HashService {
|
||||
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
|
||||
/// with hash for those that were successfully hashed. Hashes are looked up in a table
|
||||
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
|
||||
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash) async {
|
||||
Future<void> _hashAssets(List<LocalAsset> assetsToHash) async {
|
||||
int bytesProcessed = 0;
|
||||
final toHash = <_AssetToPath>[];
|
||||
|
||||
@@ -74,9 +72,6 @@ class HashService {
|
||||
|
||||
final file = await _storageRepository.getFileForAsset(asset.id);
|
||||
if (file == null) {
|
||||
_log.warning(
|
||||
"Cannot get file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt} from album: ${album.name}",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -84,17 +79,17 @@ class HashService {
|
||||
toHash.add(_AssetToPath(asset: asset, path: file.path));
|
||||
|
||||
if (toHash.length >= batchFileLimit || bytesProcessed >= batchSizeLimit) {
|
||||
await _processBatch(album, toHash);
|
||||
await _processBatch(toHash);
|
||||
toHash.clear();
|
||||
bytesProcessed = 0;
|
||||
}
|
||||
}
|
||||
|
||||
await _processBatch(album, toHash);
|
||||
await _processBatch(toHash);
|
||||
}
|
||||
|
||||
/// Processes a batch of assets.
|
||||
Future<void> _processBatch(LocalAlbum album, List<_AssetToPath> toHash) async {
|
||||
Future<void> _processBatch(List<_AssetToPath> toHash) async {
|
||||
if (toHash.isEmpty) {
|
||||
return;
|
||||
}
|
||||
@@ -119,9 +114,7 @@ class HashService {
|
||||
if (hash?.length == 20) {
|
||||
hashed.add(asset.copyWith(checksum: base64.encode(hash!)));
|
||||
} else {
|
||||
_log.warning(
|
||||
"Failed to hash file for ${asset.id}: ${asset.name} created at ${asset.createdAt} from album: ${album.name}",
|
||||
);
|
||||
_log.warning("Failed to hash file for ${asset.id}: ${asset.name} created at ${asset.createdAt}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/utils/datetime_helpers.dart';
|
||||
import 'package:immich_mobile/utils/diff.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:platform/platform.dart';
|
||||
@@ -286,7 +285,7 @@ extension on Iterable<PlatformAlbum> {
|
||||
(e) => LocalAlbum(
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
updatedAt: tryFromSecondsSinceEpoch(e.updatedAt) ?? DateTime.now(),
|
||||
updatedAt: e.updatedAt == null ? DateTime.now() : DateTime.fromMillisecondsSinceEpoch(e.updatedAt! * 1000),
|
||||
assetCount: e.assetCount,
|
||||
),
|
||||
).toList();
|
||||
@@ -301,8 +300,8 @@ extension on Iterable<PlatformAsset> {
|
||||
name: e.name,
|
||||
checksum: null,
|
||||
type: AssetType.values.elementAtOrNull(e.type) ?? AssetType.other,
|
||||
createdAt: tryFromSecondsSinceEpoch(e.createdAt) ?? DateTime.now(),
|
||||
updatedAt: tryFromSecondsSinceEpoch(e.updatedAt) ?? DateTime.now(),
|
||||
createdAt: e.createdAt == null ? DateTime.now() : DateTime.fromMillisecondsSinceEpoch(e.createdAt! * 1000),
|
||||
updatedAt: e.updatedAt == null ? DateTime.now() : DateTime.fromMillisecondsSinceEpoch(e.updatedAt! * 1000),
|
||||
width: e.width,
|
||||
height: e.height,
|
||||
durationInSeconds: e.durationInSeconds,
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:cronet_http/cronet_http.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:ffi/ffi.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||
|
||||
part 'local_image_request.dart';
|
||||
part 'thumbhash_image_request.dart';
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
part of 'image_request.dart';
|
||||
|
||||
class RemoteImageRequest extends ImageRequest {
|
||||
static final log = Logger('RemoteImageRequest');
|
||||
static final client = HttpClient()..maxConnectionsPerHost = 16;
|
||||
final RemoteCacheManager? cacheManager;
|
||||
static final _client = const NetworkRepository().getHttpClient(
|
||||
'thumbnails',
|
||||
diskCapacity: kThumbnailDiskCacheSize,
|
||||
memoryCapacity: 0,
|
||||
maxConnections: 16,
|
||||
cacheMode: CacheMode.disk,
|
||||
);
|
||||
final String uri;
|
||||
final Map<String, String> headers;
|
||||
HttpClientRequest? _request;
|
||||
final abortTrigger = Completer<void>();
|
||||
|
||||
RemoteImageRequest({required this.uri, required this.headers, this.cacheManager});
|
||||
RemoteImageRequest({required this.uri, required this.headers});
|
||||
|
||||
@override
|
||||
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
|
||||
@@ -16,15 +20,8 @@ class RemoteImageRequest extends ImageRequest {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: the cache manager makes everything sequential with its DB calls and its operations cannot be cancelled,
|
||||
// so it ends up being a bottleneck. We only prefer fetching from it when it can skip the DB call.
|
||||
final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: true);
|
||||
if (cachedFileImage != null) {
|
||||
return cachedFileImage;
|
||||
}
|
||||
|
||||
try {
|
||||
final buffer = await _downloadImage(uri);
|
||||
final buffer = await _downloadImage();
|
||||
if (buffer == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -35,57 +32,41 @@ class RemoteImageRequest extends ImageRequest {
|
||||
return null;
|
||||
}
|
||||
|
||||
final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: false);
|
||||
if (cachedFileImage != null) {
|
||||
return cachedFileImage;
|
||||
}
|
||||
|
||||
rethrow;
|
||||
} finally {
|
||||
_request = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<ImmutableBuffer?> _downloadImage(String url) async {
|
||||
Future<ImmutableBuffer?> _downloadImage() async {
|
||||
if (_isCancelled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final request = _request = await client.getUrl(Uri.parse(url));
|
||||
if (_isCancelled) {
|
||||
request.abort();
|
||||
return _request = null;
|
||||
}
|
||||
|
||||
for (final entry in headers.entries) {
|
||||
request.headers.set(entry.key, entry.value);
|
||||
}
|
||||
final response = await request.close();
|
||||
final req = http.AbortableRequest('GET', Uri.parse(uri), abortTrigger: abortTrigger.future);
|
||||
req.headers.addAll(headers);
|
||||
final res = await _client.send(req);
|
||||
if (_isCancelled) {
|
||||
_onCancelled();
|
||||
return null;
|
||||
}
|
||||
|
||||
final cacheManager = this.cacheManager;
|
||||
final streamController = StreamController<List<int>>(sync: true);
|
||||
final Stream<List<int>> stream;
|
||||
cacheManager?.putStreamedFile(url, streamController.stream);
|
||||
stream = response.map((chunk) {
|
||||
if (res.statusCode != 200) {
|
||||
throw Exception('Failed to download $uri: ${res.statusCode}');
|
||||
}
|
||||
|
||||
final stream = res.stream.map((chunk) {
|
||||
if (_isCancelled) {
|
||||
throw StateError('Cancelled request');
|
||||
}
|
||||
if (cacheManager != null) {
|
||||
streamController.add(chunk);
|
||||
}
|
||||
return chunk;
|
||||
});
|
||||
|
||||
try {
|
||||
final Uint8List bytes = await _downloadBytes(stream, response.contentLength);
|
||||
streamController.close();
|
||||
final Uint8List bytes = await _downloadBytes(stream, res.contentLength ?? -1);
|
||||
if (_isCancelled) {
|
||||
return null;
|
||||
}
|
||||
return await ImmutableBuffer.fromUint8List(bytes);
|
||||
} catch (e) {
|
||||
streamController.addError(e);
|
||||
streamController.close();
|
||||
if (_isCancelled) {
|
||||
return null;
|
||||
}
|
||||
@@ -122,40 +103,6 @@ class RemoteImageRequest extends ImageRequest {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
Future<ImageInfo?> _loadCachedFile(
|
||||
String url,
|
||||
ImageDecoderCallback decode,
|
||||
double scale, {
|
||||
required bool inMemoryOnly,
|
||||
}) async {
|
||||
final cacheManager = this.cacheManager;
|
||||
if (_isCancelled || cacheManager == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final file = await (inMemoryOnly ? cacheManager.getFileFromMemory(url) : cacheManager.getFileFromCache(url));
|
||||
if (_isCancelled || file == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final buffer = await ImmutableBuffer.fromFilePath(file.file.path);
|
||||
return await _decodeBuffer(buffer, decode, scale);
|
||||
} catch (e) {
|
||||
log.severe('Failed to decode cached image', e);
|
||||
_evictFile(url);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _evictFile(String url) async {
|
||||
try {
|
||||
await cacheManager?.removeFile(url);
|
||||
} catch (e) {
|
||||
log.severe('Failed to remove cached image', e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<ImageInfo?> _decodeBuffer(ImmutableBuffer buffer, ImageDecoderCallback decode, scale) async {
|
||||
if (_isCancelled) {
|
||||
buffer.dispose();
|
||||
@@ -173,7 +120,6 @@ class RemoteImageRequest extends ImageRequest {
|
||||
|
||||
@override
|
||||
void _onCancelled() {
|
||||
_request?.abort();
|
||||
_request = null;
|
||||
abortTrigger.complete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cronet_http/cronet_http.dart';
|
||||
import 'package:cupertino_http/cupertino_http.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:immich_mobile/utils/user_agent.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
class NetworkRepository {
|
||||
static late Directory _cachePath;
|
||||
static late String _userAgent;
|
||||
static final _clients = <String, http.Client>{};
|
||||
|
||||
static Future<void> init() {
|
||||
return (
|
||||
getTemporaryDirectory().then((cachePath) => _cachePath = cachePath),
|
||||
getUserAgentString().then((userAgent) => _userAgent = userAgent),
|
||||
).wait;
|
||||
}
|
||||
|
||||
static void reset() {
|
||||
Future.microtask(init);
|
||||
for (final client in _clients.values) {
|
||||
client.close();
|
||||
}
|
||||
_clients.clear();
|
||||
}
|
||||
|
||||
const NetworkRepository();
|
||||
|
||||
http.Client getHttpClient(
|
||||
String directoryName, {
|
||||
int diskCapacity = 100 << 20,
|
||||
int memoryCapacity = 10 << 20,
|
||||
int maxConnections = 6,
|
||||
CacheMode cacheMode = CacheMode.disk,
|
||||
}) {
|
||||
final cachedClient = _clients[directoryName];
|
||||
if (cachedClient != null) {
|
||||
return cachedClient;
|
||||
}
|
||||
|
||||
final directory = Directory('${_cachePath.path}/$directoryName');
|
||||
directory.createSync(recursive: true);
|
||||
if (Platform.isAndroid) {
|
||||
final engine = CronetEngine.build(
|
||||
cacheMode: cacheMode,
|
||||
cacheMaxSize: diskCapacity,
|
||||
storagePath: directory.path,
|
||||
userAgent: _userAgent,
|
||||
);
|
||||
return _clients[directoryName] = CronetClient.fromCronetEngine(engine, closeEngine: true);
|
||||
}
|
||||
|
||||
final config = URLSessionConfiguration.defaultSessionConfiguration()
|
||||
..httpMaximumConnectionsPerHost = maxConnections
|
||||
..cache = URLCache.withCapacity(
|
||||
diskCapacity: diskCapacity,
|
||||
memoryCapacity: memoryCapacity,
|
||||
directory: directory.uri,
|
||||
)
|
||||
..httpAdditionalHeaders = {'User-Agent': _userAgent};
|
||||
return _clients[directoryName] = CupertinoClient.fromSessionConfiguration(config);
|
||||
}
|
||||
}
|
||||
@@ -16,13 +16,6 @@ class StorageRepository {
|
||||
file = await entity?.originFile;
|
||||
if (file == null) {
|
||||
log.warning("Cannot get file for asset $assetId");
|
||||
return null;
|
||||
}
|
||||
|
||||
final exists = await file.exists();
|
||||
if (!exists) {
|
||||
log.warning("File for asset $assetId does not exist");
|
||||
return null;
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
log.warning("Error getting file for asset $assetId", error, stackTrace);
|
||||
@@ -41,13 +34,6 @@ class StorageRepository {
|
||||
log.warning(
|
||||
"Cannot get motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
final exists = await file.exists();
|
||||
if (!exists) {
|
||||
log.warning("Motion file for asset ${asset.id} does not exist");
|
||||
return null;
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
log.warning(
|
||||
|
||||
@@ -4,11 +4,13 @@ import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class SyncApiRepository {
|
||||
static final _client = const NetworkRepository().getHttpClient('api');
|
||||
final Logger _logger = Logger('SyncApiRepository');
|
||||
final ApiService _api;
|
||||
SyncApiRepository(this._api);
|
||||
@@ -20,10 +22,8 @@ class SyncApiRepository {
|
||||
Future<void> streamChanges(
|
||||
Function(List<SyncEvent>, Function() abort) onData, {
|
||||
int batchSize = kSyncEventBatchSize,
|
||||
http.Client? httpClient,
|
||||
}) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final client = httpClient ?? http.Client();
|
||||
final endpoint = "${_api.apiClient.basePath}/sync/stream";
|
||||
|
||||
final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'};
|
||||
@@ -70,7 +70,7 @@ class SyncApiRepository {
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await client.send(request);
|
||||
final response = await _client.send(request);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
final errorBody = await response.stream.bytesToString();
|
||||
@@ -101,8 +101,6 @@ class SyncApiRepository {
|
||||
} catch (error, stack) {
|
||||
_logger.severe("Error processing stream", error, stack);
|
||||
return Future.error(error, stack);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
stopwatch.stop();
|
||||
_logger.info("Remote Sync completed in ${stopwatch.elapsed.inMilliseconds}ms");
|
||||
|
||||
@@ -15,7 +15,9 @@ import 'package:immich_mobile/constants/locales.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
@@ -25,6 +27,7 @@ import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/theme.provider.dart';
|
||||
import 'package:immich_mobile/routing/app_navigation_observer.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/services/background.service.dart';
|
||||
import 'package:immich_mobile/services/deep_link.service.dart';
|
||||
import 'package:immich_mobile/services/local_notification.service.dart';
|
||||
@@ -112,6 +115,8 @@ Future<void> initApp() async {
|
||||
yield LicenseEntryWithLineBreaks([license.key], license.value);
|
||||
}
|
||||
});
|
||||
|
||||
await NetworkRepository.init();
|
||||
}
|
||||
|
||||
class ImmichApp extends ConsumerStatefulWidget {
|
||||
@@ -204,11 +209,14 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// needs to be delayed so that EasyLocalization is working
|
||||
if (Store.isBetaTimelineEnabled) {
|
||||
ref.read(backgroundServiceProvider).disableService();
|
||||
ref.read(driftBackgroundUploadFgService).enable();
|
||||
ref.read(driftBackgroundUploadFgService).enableSyncService();
|
||||
if (ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup)) {
|
||||
ref.read(backgroundServiceProvider).disableService();
|
||||
ref.read(driftBackgroundUploadFgService).enableUploadService();
|
||||
}
|
||||
} else {
|
||||
ref.read(driftBackgroundUploadFgService).disable();
|
||||
ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
|
||||
ref.read(driftBackgroundUploadFgService).disableUploadService();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -221,6 +229,14 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void reassemble() {
|
||||
if (kDebugMode) {
|
||||
NetworkRepository.reset();
|
||||
}
|
||||
super.reassemble();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final router = ref.watch(appRouterProvider);
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
@@ -42,10 +43,12 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||
|
||||
await ref.read(backgroundSyncProvider).syncRemote();
|
||||
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
|
||||
await ref.read(driftBackgroundUploadFgService).enableUploadService();
|
||||
await ref.read(driftBackupProvider.notifier).startBackup(currentUser.id);
|
||||
}
|
||||
|
||||
Future<void> stopBackup() async {
|
||||
await ref.read(driftBackgroundUploadFgService).disableUploadService();
|
||||
await ref.read(driftBackupProvider.notifier).cancel();
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ class _ChangeExperiencePageState extends ConsumerState<ChangeExperiencePage> {
|
||||
ref.read(readonlyModeProvider.notifier).setReadonlyMode(false);
|
||||
await migrateStoreToIsar(ref.read(isarProvider), ref.read(driftProvider));
|
||||
await ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
|
||||
await ref.read(driftBackgroundUploadFgService).disable();
|
||||
await ref.read(driftBackgroundUploadFgService).disableUploadService();
|
||||
}
|
||||
|
||||
await IsarStoreRepository(ref.read(isarProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta);
|
||||
|
||||
85
mobile/lib/platform/background_worker_api.g.dart
generated
85
mobile/lib/platform/background_worker_api.g.dart
generated
@@ -59,9 +59,9 @@ class BackgroundWorkerFgHostApi {
|
||||
|
||||
final String pigeonVar_messageChannelSuffix;
|
||||
|
||||
Future<void> enable() async {
|
||||
Future<void> enableSyncWorker() async {
|
||||
final String pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable$pigeonVar_messageChannelSuffix';
|
||||
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableSyncWorker$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -82,9 +82,32 @@ class BackgroundWorkerFgHostApi {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disable() async {
|
||||
Future<void> enableUploadWorker(int callbackHandle) async {
|
||||
final String pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$pigeonVar_messageChannelSuffix';
|
||||
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[callbackHandle]);
|
||||
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disableUploadWorker() async {
|
||||
final String pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disableUploadWorker$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -141,34 +164,13 @@ class BackgroundWorkerBgHostApi {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
final String pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.close$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class BackgroundWorkerFlutterApi {
|
||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||
|
||||
Future<void> onLocalSync(int? maxSeconds);
|
||||
|
||||
Future<void> onIosUpload(bool isRefresh, int? maxSeconds);
|
||||
|
||||
Future<void> onAndroidUpload();
|
||||
@@ -181,6 +183,35 @@ abstract class BackgroundWorkerFlutterApi {
|
||||
String messageChannelSuffix = '',
|
||||
}) {
|
||||
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
{
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onLocalSync$messageChannelSuffix',
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: binaryMessenger,
|
||||
);
|
||||
if (api == null) {
|
||||
pigeonVar_channel.setMessageHandler(null);
|
||||
} else {
|
||||
pigeonVar_channel.setMessageHandler((Object? message) async {
|
||||
assert(
|
||||
message != null,
|
||||
'Argument for dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onLocalSync was null.',
|
||||
);
|
||||
final List<Object?> args = (message as List<Object?>?)!;
|
||||
final int? arg_maxSeconds = (args[0] as int?);
|
||||
try {
|
||||
await api.onLocalSync(arg_maxSeconds);
|
||||
return wrapResponse(empty: true);
|
||||
} on PlatformException catch (e) {
|
||||
return wrapResponse(error: e);
|
||||
} catch (e) {
|
||||
return wrapResponse(
|
||||
error: PlatformException(code: 'error', message: e.toString()),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
{
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload$messageChannelSuffix',
|
||||
|
||||
@@ -62,6 +62,11 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
|
||||
return;
|
||||
}
|
||||
yield image;
|
||||
} catch (e) {
|
||||
evict();
|
||||
if (!isCancelled) {
|
||||
_log.severe('Error loading image', e);
|
||||
}
|
||||
} finally {
|
||||
this.request = null;
|
||||
}
|
||||
|
||||
@@ -7,13 +7,11 @@ import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
|
||||
class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
|
||||
with CancellableImageProviderMixin<RemoteThumbProvider> {
|
||||
static final cacheManager = RemoteThumbnailCacheManager();
|
||||
final String assetId;
|
||||
|
||||
RemoteThumbProvider({required this.assetId});
|
||||
@@ -39,7 +37,6 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
|
||||
final request = this.request = RemoteImageRequest(
|
||||
uri: getThumbnailUrlForRemoteId(key.assetId),
|
||||
headers: ApiService.getRequestHeaders(),
|
||||
cacheManager: cacheManager,
|
||||
);
|
||||
return loadRequest(request, decode);
|
||||
}
|
||||
@@ -60,7 +57,6 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
|
||||
|
||||
class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider>
|
||||
with CancellableImageProviderMixin<RemoteFullImageProvider> {
|
||||
static final cacheManager = RemoteThumbnailCacheManager();
|
||||
final String assetId;
|
||||
|
||||
RemoteFullImageProvider({required this.assetId});
|
||||
@@ -92,11 +88,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
||||
}
|
||||
|
||||
final headers = ApiService.getRequestHeaders();
|
||||
final request = this.request = RemoteImageRequest(
|
||||
uri: getPreviewUrlForRemoteId(key.assetId),
|
||||
headers: headers,
|
||||
cacheManager: cacheManager,
|
||||
);
|
||||
final request = this.request = RemoteImageRequest(uri: getPreviewUrlForRemoteId(key.assetId), headers: headers);
|
||||
yield* loadRequest(request, decode);
|
||||
|
||||
if (isCancelled) {
|
||||
|
||||
@@ -2,9 +2,11 @@ import 'dart:ui';
|
||||
|
||||
const double kTimelineHeaderExtent = 80.0;
|
||||
const Size kTimelineFixedTileExtent = Size.square(256);
|
||||
const Size kThumbnailResolution = Size.square(320); // TODO: make the resolution vary based on actual tile size
|
||||
const double kTimelineSpacing = 2.0;
|
||||
const int kTimelineColumnCount = 3;
|
||||
|
||||
const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300);
|
||||
const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800);
|
||||
|
||||
const Size kThumbnailResolution = Size.square(320); // TODO: make the resolution vary based on actual tile size
|
||||
const kThumbnailDiskCacheSize = 1024 << 20; // 1GiB
|
||||
|
||||
@@ -1,148 +1,25 @@
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
// ignore: implementation_imports
|
||||
import 'package:flutter_cache_manager/src/cache_store.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
abstract class RemoteCacheManager extends CacheManager {
|
||||
static final _log = Logger('RemoteCacheManager');
|
||||
|
||||
RemoteCacheManager.custom(super.config, CacheStore store)
|
||||
// Unfortunately, CacheStore is not a public API
|
||||
// ignore: invalid_use_of_visible_for_testing_member
|
||||
: super.custom(cacheStore: store);
|
||||
|
||||
Future<void> putStreamedFile(
|
||||
String url,
|
||||
Stream<List<int>> source, {
|
||||
String? key,
|
||||
String? eTag,
|
||||
Duration maxAge = const Duration(days: 30),
|
||||
String fileExtension = 'file',
|
||||
});
|
||||
|
||||
// Unlike `putFileStream`, this method handles request cancellation,
|
||||
// does not make a (slow) DB call checking if the file is already cached,
|
||||
// does not synchronously check if a file exists,
|
||||
// and deletes the file on cancellation without making these checks again.
|
||||
Future<void> putStreamedFileToStore(
|
||||
CacheStore store,
|
||||
String url,
|
||||
Stream<List<int>> source, {
|
||||
String? key,
|
||||
String? eTag,
|
||||
Duration maxAge = const Duration(days: 30),
|
||||
String fileExtension = 'file',
|
||||
}) async {
|
||||
final path = '${const Uuid().v1()}.$fileExtension';
|
||||
final file = await store.fileSystem.createFile(path);
|
||||
final sink = file.openWrite();
|
||||
try {
|
||||
await source.listen(sink.add, cancelOnError: true).asFuture();
|
||||
} catch (e) {
|
||||
try {
|
||||
await sink.close();
|
||||
await file.delete();
|
||||
} catch (e) {
|
||||
_log.severe('Failed to delete incomplete cache file: $e');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sink.flush();
|
||||
await sink.close();
|
||||
} catch (e) {
|
||||
try {
|
||||
await file.delete();
|
||||
} catch (e) {
|
||||
_log.severe('Failed to delete incomplete cache file: $e');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final cacheObject = CacheObject(
|
||||
url,
|
||||
key: key,
|
||||
relativePath: path,
|
||||
validTill: DateTime.now().add(maxAge),
|
||||
eTag: eTag,
|
||||
);
|
||||
try {
|
||||
await store.putFile(cacheObject);
|
||||
} catch (e) {
|
||||
try {
|
||||
await file.delete();
|
||||
} catch (e) {
|
||||
_log.severe('Failed to delete untracked cache file: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteImageCacheManager extends RemoteCacheManager {
|
||||
class RemoteImageCacheManager extends CacheManager {
|
||||
static const key = 'remoteImageCacheKey';
|
||||
static final RemoteImageCacheManager _instance = RemoteImageCacheManager._();
|
||||
static final _config = Config(key, maxNrOfCacheObjects: 500, stalePeriod: const Duration(days: 30));
|
||||
static final _store = CacheStore(_config);
|
||||
|
||||
factory RemoteImageCacheManager() {
|
||||
return _instance;
|
||||
}
|
||||
|
||||
RemoteImageCacheManager._() : super.custom(_config, _store);
|
||||
|
||||
@override
|
||||
Future<void> putStreamedFile(
|
||||
String url,
|
||||
Stream<List<int>> source, {
|
||||
String? key,
|
||||
String? eTag,
|
||||
Duration maxAge = const Duration(days: 30),
|
||||
String fileExtension = 'file',
|
||||
}) {
|
||||
return putStreamedFileToStore(
|
||||
_store,
|
||||
url,
|
||||
source,
|
||||
key: key,
|
||||
eTag: eTag,
|
||||
maxAge: maxAge,
|
||||
fileExtension: fileExtension,
|
||||
);
|
||||
}
|
||||
RemoteImageCacheManager._() : super(_config);
|
||||
}
|
||||
|
||||
/// The cache manager for full size images [ImmichRemoteImageProvider]
|
||||
class RemoteThumbnailCacheManager extends RemoteCacheManager {
|
||||
class RemoteThumbnailCacheManager extends CacheManager {
|
||||
static const key = 'remoteThumbnailCacheKey';
|
||||
static final RemoteThumbnailCacheManager _instance = RemoteThumbnailCacheManager._();
|
||||
static final _config = Config(key, maxNrOfCacheObjects: 5000, stalePeriod: const Duration(days: 30));
|
||||
static final _store = CacheStore(_config);
|
||||
|
||||
factory RemoteThumbnailCacheManager() {
|
||||
return _instance;
|
||||
}
|
||||
|
||||
RemoteThumbnailCacheManager._() : super.custom(_config, _store);
|
||||
|
||||
@override
|
||||
Future<void> putStreamedFile(
|
||||
String url,
|
||||
Stream<List<int>> source, {
|
||||
String? key,
|
||||
String? eTag,
|
||||
Duration maxAge = const Duration(days: 30),
|
||||
String fileExtension = 'file',
|
||||
}) {
|
||||
return putStreamedFileToStore(
|
||||
_store,
|
||||
url,
|
||||
source,
|
||||
key: key,
|
||||
eTag: eTag,
|
||||
maxAge: maxAge,
|
||||
fileExtension: fileExtension,
|
||||
);
|
||||
}
|
||||
RemoteThumbnailCacheManager._() : super(_config);
|
||||
}
|
||||
|
||||
@@ -4,15 +4,16 @@ import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:immich_mobile/utils/user_agent.dart';
|
||||
|
||||
class ApiService implements Authentication {
|
||||
static final _client = const NetworkRepository().getHttpClient('api');
|
||||
late ApiClient _apiClient;
|
||||
|
||||
late UsersApi usersApi;
|
||||
@@ -50,6 +51,7 @@ class ApiService implements Authentication {
|
||||
|
||||
setEndpoint(String endpoint) {
|
||||
_apiClient = ApiClient(basePath: endpoint, authentication: this);
|
||||
_apiClient.client = _client;
|
||||
_setUserAgentHeader();
|
||||
if (_accessToken != null) {
|
||||
setAccessToken(_accessToken!);
|
||||
@@ -134,13 +136,11 @@ class ApiService implements Authentication {
|
||||
}
|
||||
|
||||
Future<String> _getWellKnownEndpoint(String baseUrl) async {
|
||||
final Client client = Client();
|
||||
|
||||
try {
|
||||
var headers = {"Accept": "application/json"};
|
||||
headers.addAll(getRequestHeaders());
|
||||
|
||||
final res = await client
|
||||
final res = await _client
|
||||
.get(Uri.parse("$baseUrl/.well-known/immich"), headers: headers)
|
||||
.timeout(const Duration(seconds: 5));
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
const int _maxMillisecondsSinceEpoch = 8640000000000000; // 275760-09-13
|
||||
const int _minMillisecondsSinceEpoch = -62135596800000; // 0001-01-01
|
||||
|
||||
DateTime? tryFromSecondsSinceEpoch(int? secondsSinceEpoch) {
|
||||
if (secondsSinceEpoch == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final milliSeconds = secondsSinceEpoch * 1000;
|
||||
if (milliSeconds < _minMillisecondsSinceEpoch || milliSeconds > _maxMillisecondsSinceEpoch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return DateTime.fromMillisecondsSinceEpoch(milliSeconds);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
4
mobile/openapi/lib/api/assets_api.dart
generated
4
mobile/openapi/lib/api/assets_api.dart
generated
@@ -18,7 +18,7 @@ class AssetsApi {
|
||||
|
||||
/// checkBulkUpload
|
||||
///
|
||||
/// Checks if assets exist by checksums. This endpoint requires the `asset.upload` permission.
|
||||
/// Checks if assets exist by checksums
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
@@ -52,7 +52,7 @@ class AssetsApi {
|
||||
|
||||
/// checkBulkUpload
|
||||
///
|
||||
/// Checks if assets exist by checksums. This endpoint requires the `asset.upload` permission.
|
||||
/// Checks if assets exist by checksums
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
|
||||
@@ -13,9 +13,13 @@ import 'package:pigeon/pigeon.dart';
|
||||
)
|
||||
@HostApi()
|
||||
abstract class BackgroundWorkerFgHostApi {
|
||||
void enable();
|
||||
void enableSyncWorker();
|
||||
|
||||
void disable();
|
||||
// Enables the background upload service with the given callback handle
|
||||
void enableUploadWorker(int callbackHandle);
|
||||
|
||||
// Disables the background upload service
|
||||
void disableUploadWorker();
|
||||
}
|
||||
|
||||
@HostApi()
|
||||
@@ -23,12 +27,14 @@ abstract class BackgroundWorkerBgHostApi {
|
||||
// Called from the background flutter engine when it has bootstrapped and established the
|
||||
// required platform channels to notify the native side to start the background upload
|
||||
void onInitialized();
|
||||
|
||||
void close();
|
||||
}
|
||||
|
||||
@FlutterApi()
|
||||
abstract class BackgroundWorkerFlutterApi {
|
||||
// Android & iOS: Called when the local sync is triggered
|
||||
@async
|
||||
void onLocalSync(int? maxSeconds);
|
||||
|
||||
// iOS Only: Called when the iOS background upload is triggered
|
||||
@async
|
||||
void onIosUpload(bool isRefresh, int? maxSeconds);
|
||||
|
||||
@@ -337,6 +337,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
cronet_http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cronet_http
|
||||
sha256: "1b99ad5ae81aa9d2f12900e5f17d3681f3828629bb7f7fe7ad88076a34209840"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
crop_image:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -369,6 +377,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
cupertino_http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cupertino_http
|
||||
sha256: "72187f715837290a63479a5b0ae709f4fedad0ed6bd0441c275eceaa02d5abae"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
custom_lint:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -899,10 +915,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f
|
||||
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
version: "1.5.0"
|
||||
http_multi_server:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -919,6 +935,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
http_profile:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_profile
|
||||
sha256: "7e679e355b09aaee2ab5010915c932cce3f2d1c11c3b2dc177891687014ffa78"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.0"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1044,6 +1068,14 @@ packages:
|
||||
url: "https://github.com/immich-app/isar"
|
||||
source: git
|
||||
version: "3.1.8"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni
|
||||
sha256: d2c361082d554d4593c3012e26f6b188f902acd291330f13d6427641a92b3da1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.14.2"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1237,6 +1269,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.0"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "9f034ba1eeca53ddb339bc8f4813cb07336a849cd735559b60cdc068ecce2dc7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.1.0"
|
||||
octo_image:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -90,6 +90,8 @@ dependencies:
|
||||
# DB
|
||||
drift: ^2.23.1
|
||||
drift_flutter: ^0.2.4
|
||||
cronet_http: ^1.5.0
|
||||
cupertino_http: ^2.3.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/utils/datetime_helpers.dart';
|
||||
|
||||
void main() {
|
||||
group('tryFromSecondsSinceEpoch', () {
|
||||
test('returns null for null input', () {
|
||||
final result = tryFromSecondsSinceEpoch(null);
|
||||
expect(result, isNull);
|
||||
});
|
||||
|
||||
test('returns null for value below minimum allowed range', () {
|
||||
// _minMillisecondsSinceEpoch = -62135596800000
|
||||
final seconds = -62135596800000 ~/ 1000 - 1; // One second before min allowed
|
||||
final result = tryFromSecondsSinceEpoch(seconds);
|
||||
expect(result, isNull);
|
||||
});
|
||||
|
||||
test('returns null for value above maximum allowed range', () {
|
||||
// _maxMillisecondsSinceEpoch = 8640000000000000
|
||||
final seconds = 8640000000000000 ~/ 1000 + 1; // One second after max allowed
|
||||
final result = tryFromSecondsSinceEpoch(seconds);
|
||||
expect(result, isNull);
|
||||
});
|
||||
|
||||
test('returns correct DateTime for minimum allowed value', () {
|
||||
final seconds = -62135596800000 ~/ 1000; // Minimum allowed timestamp
|
||||
final result = tryFromSecondsSinceEpoch(seconds);
|
||||
expect(result, DateTime.fromMillisecondsSinceEpoch(-62135596800000));
|
||||
});
|
||||
|
||||
test('returns correct DateTime for maximum allowed value', () {
|
||||
final seconds = 8640000000000000 ~/ 1000; // Maximum allowed timestamp
|
||||
final result = tryFromSecondsSinceEpoch(seconds);
|
||||
expect(result, DateTime.fromMillisecondsSinceEpoch(8640000000000000));
|
||||
});
|
||||
|
||||
test('returns correct DateTime for negative timestamp', () {
|
||||
final seconds = -1577836800; // Dec 31, 1919 (pre-epoch)
|
||||
final result = tryFromSecondsSinceEpoch(seconds);
|
||||
expect(result, DateTime.fromMillisecondsSinceEpoch(-1577836800 * 1000));
|
||||
});
|
||||
|
||||
test('returns correct DateTime for zero timestamp', () {
|
||||
final seconds = 0; // Jan 1, 1970 (epoch)
|
||||
final result = tryFromSecondsSinceEpoch(seconds);
|
||||
expect(result, DateTime.fromMillisecondsSinceEpoch(0));
|
||||
});
|
||||
|
||||
test('returns correct DateTime for recent timestamp', () {
|
||||
final now = DateTime.now();
|
||||
final seconds = now.millisecondsSinceEpoch ~/ 1000;
|
||||
final result = tryFromSecondsSinceEpoch(seconds);
|
||||
expect(result?.year, now.year);
|
||||
expect(result?.month, now.month);
|
||||
expect(result?.day, now.day);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1855,7 +1855,7 @@
|
||||
},
|
||||
"/assets/bulk-upload-check": {
|
||||
"post": {
|
||||
"description": "Checks if assets exist by checksums. This endpoint requires the `asset.upload` permission.",
|
||||
"description": "Checks if assets exist by checksums",
|
||||
"operationId": "checkBulkUpload",
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
@@ -1894,8 +1894,7 @@
|
||||
"summary": "checkBulkUpload",
|
||||
"tags": [
|
||||
"Assets"
|
||||
],
|
||||
"x-immich-permission": "asset.upload"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/assets/device/{deviceId}": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# dev build
|
||||
FROM ghcr.io/immich-app/base-server-dev:202509021104@sha256:47d38c94775332000a93fbbeca1c796687b2d2919e3c75b6e26ab8a65d1864f3 AS dev
|
||||
FROM ghcr.io/immich-app/base-server-dev:202508191104@sha256:0608857ef682099c458f0fb319afdcaf09462bbb5670b6dcd3642029f12eee1c AS dev
|
||||
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
CI=1 \
|
||||
@@ -77,7 +77,7 @@ RUN apt-get update \
|
||||
RUN dart --disable-analytics
|
||||
|
||||
# production-builder-base image
|
||||
FROM ghcr.io/immich-app/base-server-dev:202509021104@sha256:47d38c94775332000a93fbbeca1c796687b2d2919e3c75b6e26ab8a65d1864f3 AS prod-builder-base
|
||||
FROM ghcr.io/immich-app/base-server-dev:202508191104@sha256:0608857ef682099c458f0fb319afdcaf09462bbb5670b6dcd3642029f12eee1c AS prod-builder-base
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
CI=1 \
|
||||
COREPACK_HOME=/tmp
|
||||
@@ -115,7 +115,7 @@ RUN pnpm --filter @immich/sdk --filter @immich/cli --frozen-lockfile install &&
|
||||
pnpm --filter @immich/cli --prod --no-optional deploy /output/cli-pruned
|
||||
|
||||
# prod base image
|
||||
FROM ghcr.io/immich-app/base-server-prod:202509021104@sha256:84f3727cff75c623f79236cdd9a2b72c84f7665057f474851016f702c67157af
|
||||
FROM ghcr.io/immich-app/base-server-prod:202508191104@sha256:4cce4119f5555fce5e383b681e4feea31956ceadb94cafcbcbbae2c7b94a1b62
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
ENV NODE_ENV=production \
|
||||
|
||||
@@ -188,7 +188,7 @@ export class AssetMediaController {
|
||||
* Checks if assets exist by checksums
|
||||
*/
|
||||
@Post('bulk-upload-check')
|
||||
@Authenticated({ permission: Permission.AssetUpload })
|
||||
@Authenticated()
|
||||
@ApiOperation({
|
||||
summary: 'checkBulkUpload',
|
||||
description: 'Checks if assets exist by checksums',
|
||||
|
||||
@@ -12,7 +12,7 @@ import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { ImmichFile, UploadFile, UploadFiles } from 'src/types';
|
||||
import { asUploadRequest, mapToUploadFile } from 'src/utils/asset.util';
|
||||
import { asRequest, mapToUploadFile } from 'src/utils/asset.util';
|
||||
|
||||
export function getFile(files: UploadFiles, property: 'assetData' | 'sidecarData') {
|
||||
const file = files[property]?.[0];
|
||||
@@ -99,21 +99,18 @@ export class FileUploadInterceptor implements NestInterceptor {
|
||||
}
|
||||
|
||||
private fileFilter(request: AuthRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) {
|
||||
return callbackify(() => this.assetService.canUploadFile(asUploadRequest(request, file)), callback);
|
||||
return callbackify(() => this.assetService.canUploadFile(asRequest(request, file)), callback);
|
||||
}
|
||||
|
||||
private filename(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
|
||||
return callbackify(
|
||||
() => this.assetService.getUploadFilename(asUploadRequest(request, file)),
|
||||
() => this.assetService.getUploadFilename(asRequest(request, file)),
|
||||
callback as Callback<string>,
|
||||
);
|
||||
}
|
||||
|
||||
private destination(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
|
||||
return callbackify(
|
||||
() => this.assetService.getUploadFolder(asUploadRequest(request, file)),
|
||||
callback as Callback<string>,
|
||||
);
|
||||
return callbackify(() => this.assetService.getUploadFolder(asRequest(request, file)), callback as Callback<string>);
|
||||
}
|
||||
|
||||
private handleFile(request: AuthRequest, file: Express.Multer.File, callback: Callback<Partial<ImmichFile>>) {
|
||||
|
||||
@@ -25,7 +25,6 @@ const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
|
||||
const uploadFile = {
|
||||
nullAuth: {
|
||||
auth: null,
|
||||
body: {},
|
||||
fieldName: UploadFieldName.ASSET_DATA,
|
||||
file: {
|
||||
uuid: 'random-uuid',
|
||||
@@ -38,7 +37,6 @@ const uploadFile = {
|
||||
filename: (fieldName: UploadFieldName, filename: string) => {
|
||||
return {
|
||||
auth: authStub.admin,
|
||||
body: {},
|
||||
fieldName,
|
||||
file: {
|
||||
uuid: 'random-uuid',
|
||||
@@ -899,10 +897,7 @@ describe(AssetMediaService.name, () => {
|
||||
|
||||
describe('onUploadError', () => {
|
||||
it('should queue a job to delete the uploaded file', async () => {
|
||||
const request = {
|
||||
body: {},
|
||||
user: authStub.user1,
|
||||
} as AuthRequest;
|
||||
const request = { user: authStub.user1 } as AuthRequest;
|
||||
|
||||
const file = {
|
||||
fieldname: UploadFieldName.ASSET_DATA,
|
||||
|
||||
@@ -24,14 +24,20 @@ import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetStatus, AssetType, AssetVisibility, CacheControl, JobName, Permission, StorageFolder } from 'src/enum';
|
||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { UploadFile, UploadRequest } from 'src/types';
|
||||
import { UploadFile } from 'src/types';
|
||||
import { requireUploadAccess } from 'src/utils/access';
|
||||
import { asUploadRequest, getAssetFiles, onBeforeLink } from 'src/utils/asset.util';
|
||||
import { asRequest, getAssetFiles, onBeforeLink } from 'src/utils/asset.util';
|
||||
import { isAssetChecksumConstraint } from 'src/utils/database';
|
||||
import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { fromChecksum } from 'src/utils/request';
|
||||
|
||||
interface UploadRequest {
|
||||
auth: AuthDto | null;
|
||||
fieldName: UploadFieldName;
|
||||
file: UploadFile;
|
||||
}
|
||||
|
||||
export interface AssetMediaRedirectResponse {
|
||||
targetSize: AssetMediaSize | 'original';
|
||||
}
|
||||
@@ -83,15 +89,15 @@ export class AssetMediaService extends BaseService {
|
||||
throw new BadRequestException(`Unsupported file type ${filename}`);
|
||||
}
|
||||
|
||||
getUploadFilename({ auth, fieldName, file, body }: UploadRequest): string {
|
||||
getUploadFilename({ auth, fieldName, file }: UploadRequest): string {
|
||||
requireUploadAccess(auth);
|
||||
|
||||
const extension = extname(body.filename || file.originalName);
|
||||
const originalExtension = extname(file.originalName);
|
||||
|
||||
const lookup = {
|
||||
[UploadFieldName.ASSET_DATA]: extension,
|
||||
[UploadFieldName.ASSET_DATA]: originalExtension,
|
||||
[UploadFieldName.SIDECAR_DATA]: '.xmp',
|
||||
[UploadFieldName.PROFILE_DATA]: extension,
|
||||
[UploadFieldName.PROFILE_DATA]: originalExtension,
|
||||
};
|
||||
|
||||
return sanitize(`${file.uuid}${lookup[fieldName]}`);
|
||||
@@ -111,8 +117,8 @@ export class AssetMediaService extends BaseService {
|
||||
}
|
||||
|
||||
async onUploadError(request: AuthRequest, file: Express.Multer.File) {
|
||||
const uploadFilename = this.getUploadFilename(asUploadRequest(request, file));
|
||||
const uploadFolder = this.getUploadFolder(asUploadRequest(request, file));
|
||||
const uploadFilename = this.getUploadFilename(asRequest(request, file));
|
||||
const uploadFolder = this.getUploadFolder(asRequest(request, file));
|
||||
const uploadPath = `${uploadFolder}/${uploadFilename}`;
|
||||
|
||||
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [uploadPath] } });
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { VECTOR_EXTENSIONS } from 'src/constants';
|
||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
AssetMetadataKey,
|
||||
AssetOrder,
|
||||
@@ -410,16 +408,6 @@ export interface UploadFile {
|
||||
size: number;
|
||||
}
|
||||
|
||||
export type UploadRequest = {
|
||||
auth: AuthDto | null;
|
||||
fieldName: UploadFieldName;
|
||||
file: UploadFile;
|
||||
body: {
|
||||
filename?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export interface UploadFiles {
|
||||
assetData: ImmichFile[];
|
||||
sidecarData: ImmichFile[];
|
||||
|
||||
@@ -10,7 +10,7 @@ import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { EventRepository } from 'src/repositories/event.repository';
|
||||
import { PartnerRepository } from 'src/repositories/partner.repository';
|
||||
import { IBulkAsset, ImmichFile, UploadFile, UploadRequest } from 'src/types';
|
||||
import { IBulkAsset, ImmichFile, UploadFile } from 'src/types';
|
||||
import { checkAccess } from 'src/utils/access';
|
||||
|
||||
export const getAssetFile = (files: AssetFile[], type: AssetFileType | GeneratedImageType) => {
|
||||
@@ -190,10 +190,9 @@ export function mapToUploadFile(file: ImmichFile): UploadFile {
|
||||
};
|
||||
}
|
||||
|
||||
export const asUploadRequest = (request: AuthRequest, file: Express.Multer.File): UploadRequest => {
|
||||
export const asRequest = (request: AuthRequest, file: Express.Multer.File) => {
|
||||
return {
|
||||
auth: request.user || null,
|
||||
body: request.body,
|
||||
fieldName: file.fieldname as UploadFieldName,
|
||||
file: mapToUploadFile(file as ImmichFile),
|
||||
};
|
||||
|
||||
@@ -34,7 +34,8 @@ type SendFile = Parameters<Response['sendFile']>;
|
||||
type SendFileOptions = SendFile[1];
|
||||
|
||||
const cacheControlHeaders: Record<CacheControl, string | null> = {
|
||||
[CacheControl.PrivateWithCache]: 'private, max-age=86400, no-transform',
|
||||
[CacheControl.PrivateWithCache]:
|
||||
'private, max-age=86400, no-transform, stale-while-revalidate=2592000, stale-if-error=2592000',
|
||||
[CacheControl.PrivateWithoutCache]: 'private, no-cache, no-transform',
|
||||
[CacheControl.None]: null, // falsy value to prevent adding Cache-Control header
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user