Compare commits

...

10 Commits

Author SHA1 Message Date
Jason Rasmussen d892624197 chore: run on windows-2025 2025-09-03 13:23:10 -04:00
shenlong 270a0ff986 chore: log name and createdAt of asset on hash failures (#21546)
* chore: log name and createdAt of asset on hash failures

* add album name to hash failure logs

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-03 09:58:03 -05:00
shenlong 9d3f10372d refactor: simplify background worker (#21558)
* chore: log hash starting

* chore: android - bump the min worker delay

* remove local sync only task and always enqueue background workers

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-03 09:57:30 -05:00
bo0tzz 2f1385a236 chore: request LLM disclosure in PR template (#21553)
Suggestions for different wording/placeholder are welcome
2025-09-03 09:11:24 -05:00
renovate[bot] 183a285584 chore(deps): update base-image to v202509021104 (major) (#21513)
chore(deps): update base-image to v202509021104

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-02 21:20:21 -05:00
Alex 5ce946bb5b fix: null check (#21536) 2025-09-02 19:21:41 -05:00
shenlong 674faf2e57 fix: local sync task never runs on iOS (#21491)
* fix: local sync task never runs on iOS

* chore: rename ios register method

* refactor from using dart callback to dart entrypoint + more logs

* check if file exists before hashing

* reschedule local sync task

* chore: rename background worker logger

* refactor: move file exists check inside repo

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-03 02:05:58 +05:30
Alex 4f7702c6bf fix: iOS portrait photo saved as jpg extension (#21388)
remove bad merged settings

remove console log
2025-09-02 14:26:12 -05:00
bo0tzz 28edf5664d fix: set specific AssetUpload permission on checkBulkUpload endpoint (#21470)
* fix: set specific AssetUpload permission on checkBulkUpload endpoint

Fixes #21456

* fix: make open-api
2025-09-02 14:21:14 -05:00
shenlong ec2f94cae8 fix: handle datetime outside the valid range supported by dart (#21526)
* fix: handle datetime outside the valid range supported by dart

* add tests for tryFromSecondsSinceEpoch

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-02 14:20:49 -05:00
31 changed files with 586 additions and 692 deletions
+4
View File
@@ -34,3 +34,7 @@ The `/api/something` endpoint is now `/api/something-else`
- [ ] I have followed naming conventions/patterns in the surrounding code - [ ] 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/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/`) - [ ] 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.
...
+1 -1
View File
@@ -138,7 +138,7 @@ jobs:
name: Unit Test CLI (Windows) name: Unit Test CLI (Windows)
needs: pre-job needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_cli == 'true' }} if: ${{ needs.pre-job.outputs.should_run_cli == 'true' }}
runs-on: windows-latest runs-on: windows-2025
permissions: permissions:
contents: read contents: read
defaults: defaults:
@@ -61,9 +61,8 @@ private open class BackgroundWorkerPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ /** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface BackgroundWorkerFgHostApi { interface BackgroundWorkerFgHostApi {
fun enableSyncWorker() fun enable()
fun enableUploadWorker(callbackHandle: Long) fun disable()
fun disableUploadWorker()
companion object { companion object {
/** The codec used by BackgroundWorkerFgHostApi. */ /** The codec used by BackgroundWorkerFgHostApi. */
@@ -75,11 +74,11 @@ interface BackgroundWorkerFgHostApi {
fun setUp(binaryMessenger: BinaryMessenger, api: BackgroundWorkerFgHostApi?, messageChannelSuffix: String = "") { fun setUp(binaryMessenger: BinaryMessenger, api: BackgroundWorkerFgHostApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run { run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableSyncWorker$separatedMessageChannelSuffix", codec) val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable$separatedMessageChannelSuffix", codec)
if (api != null) { if (api != null) {
channel.setMessageHandler { _, reply -> channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try { val wrapped: List<Any?> = try {
api.enableSyncWorker() api.enable()
listOf(null) listOf(null)
} catch (exception: Throwable) { } catch (exception: Throwable) {
BackgroundWorkerPigeonUtils.wrapError(exception) BackgroundWorkerPigeonUtils.wrapError(exception)
@@ -91,29 +90,11 @@ interface BackgroundWorkerFgHostApi {
} }
} }
run { run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker$separatedMessageChannelSuffix", codec) val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$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) { if (api != null) {
channel.setMessageHandler { _, reply -> channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try { val wrapped: List<Any?> = try {
api.disableUploadWorker() api.disable()
listOf(null) listOf(null)
} catch (exception: Throwable) { } catch (exception: Throwable) {
BackgroundWorkerPigeonUtils.wrapError(exception) BackgroundWorkerPigeonUtils.wrapError(exception)
@@ -130,6 +111,7 @@ interface BackgroundWorkerFgHostApi {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ /** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface BackgroundWorkerBgHostApi { interface BackgroundWorkerBgHostApi {
fun onInitialized() fun onInitialized()
fun close()
companion object { companion object {
/** The codec used by BackgroundWorkerBgHostApi. */ /** The codec used by BackgroundWorkerBgHostApi. */
@@ -156,6 +138,22 @@ interface BackgroundWorkerBgHostApi {
channel.setMessageHandler(null) 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)
}
}
} }
} }
} }
@@ -167,23 +165,6 @@ class BackgroundWorkerFlutterApi(private val binaryMessenger: BinaryMessenger, p
BackgroundWorkerPigeonCodec() 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) fun onIosUpload(isRefreshArg: Boolean, maxSecondsArg: Long?, callback: (Result<Unit>) -> Unit)
{ {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
@@ -11,17 +11,11 @@ import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.SettableFuture import com.google.common.util.concurrent.SettableFuture
import io.flutter.FlutterInjector import io.flutter.FlutterInjector
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor.DartCallback import io.flutter.embedding.engine.dart.DartExecutor
import io.flutter.embedding.engine.loader.FlutterLoader import io.flutter.embedding.engine.loader.FlutterLoader
import io.flutter.view.FlutterCallbackInformation
private const val TAG = "BackgroundWorker" private const val TAG = "BackgroundWorker"
enum class BackgroundTaskType {
LOCAL_SYNC,
UPLOAD,
}
class BackgroundWorker(context: Context, params: WorkerParameters) : class BackgroundWorker(context: Context, params: WorkerParameters) :
ListenableWorker(context, params), BackgroundWorkerBgHostApi { ListenableWorker(context, params), BackgroundWorkerBgHostApi {
private val ctx: Context = context.applicationContext private val ctx: Context = context.applicationContext
@@ -58,25 +52,6 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) { loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
engine = FlutterEngine(ctx) 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 // Register custom plugins
MainActivity.registerPlugins(ctx, engine!!) MainActivity.registerPlugins(ctx, engine!!)
flutterApi = flutterApi =
@@ -86,8 +61,12 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
api = this api = this
) )
engine!!.dartExecutor.executeDartCallback( engine!!.dartExecutor.executeDartEntrypoint(
DartCallback(ctx.assets, loader.findAppBundlePath(), callback) DartExecutor.DartEntrypoint(
loader.findAppBundlePath(),
"package:immich_mobile/domain/services/background_worker.service.dart",
"backgroundSyncNativeEntrypoint"
)
) )
} }
@@ -100,23 +79,10 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
* This method acts as a bridge between the native Android background task system and Flutter. * This method acts as a bridge between the native Android background task system and Flutter.
*/ */
override fun onInitialized() { override fun onInitialized() {
val taskTypeIndex = inputData.getInt(BackgroundWorkerApiImpl.WORKER_DATA_TASK_TYPE, 0) flutterApi?.onAndroidUpload { handleHostResult(it) }
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) { if (isComplete) {
return return
} }
@@ -134,6 +100,16 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
}, 5000) }, 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>) { private fun handleHostResult(result: kotlin.Result<Unit>) {
if (isComplete) { if (isComplete) {
return return
@@ -3,10 +3,8 @@ package app.alextran.immich.background
import android.content.Context import android.content.Context
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log import android.util.Log
import androidx.core.content.edit
import androidx.work.BackoffPolicy import androidx.work.BackoffPolicy
import androidx.work.Constraints import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager import androidx.work.WorkManager
@@ -16,19 +14,13 @@ private const val TAG = "BackgroundUploadImpl"
class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi { class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
private val ctx: Context = context.applicationContext private val ctx: Context = context.applicationContext
override fun enableSyncWorker() {
override fun enable() {
enqueueMediaObserver(ctx) enqueueMediaObserver(ctx)
Log.i(TAG, "Scheduled media observer")
} }
override fun enableUploadWorker(callbackHandle: Long) { override fun disable() {
updateUploadEnabled(ctx, true) WorkManager.getInstance(ctx).cancelUniqueWork(OBSERVER_WORKER_NAME)
updateCallbackHandle(ctx, callbackHandle)
Log.i(TAG, "Scheduled background upload tasks")
}
override fun disableUploadWorker() {
updateUploadEnabled(ctx, false)
WorkManager.getInstance(ctx).cancelUniqueWork(BACKGROUND_WORKER_NAME) WorkManager.getInstance(ctx).cancelUniqueWork(BACKGROUND_WORKER_NAME)
Log.i(TAG, "Cancelled background upload tasks") Log.i(TAG, "Cancelled background upload tasks")
} }
@@ -37,32 +29,14 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
private const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1" private const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1"
private const val OBSERVER_WORKER_NAME = "immich/MediaObserverV1" 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) { fun enqueueMediaObserver(ctx: Context) {
val constraints = Constraints.Builder() val constraints = Constraints.Builder()
.addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true) .addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true) .addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true) .addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true) .addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
.setTriggerContentUpdateDelay(5, TimeUnit.SECONDS) .setTriggerContentUpdateDelay(30, TimeUnit.SECONDS)
.setTriggerContentMaxDelay(1, TimeUnit.MINUTES) .setTriggerContentMaxDelay(3, TimeUnit.MINUTES)
.build() .build()
val work = OneTimeWorkRequest.Builder(MediaObserver::class.java) val work = OneTimeWorkRequest.Builder(MediaObserver::class.java)
@@ -74,15 +48,13 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
Log.i(TAG, "Enqueued media observer worker with name: $OBSERVER_WORKER_NAME") Log.i(TAG, "Enqueued media observer worker with name: $OBSERVER_WORKER_NAME")
} }
fun enqueueBackgroundWorker(ctx: Context, taskType: BackgroundTaskType) { fun enqueueBackgroundWorker(ctx: Context) {
val constraints = Constraints.Builder().setRequiresBatteryNotLow(true).build() 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) val work = OneTimeWorkRequest.Builder(BackgroundWorker::class.java)
.setConstraints(constraints) .setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
.setInputData(data.build()).build() .build()
WorkManager.getInstance(ctx) WorkManager.getInstance(ctx)
.enqueueUniqueWork(BACKGROUND_WORKER_NAME, ExistingWorkPolicy.REPLACE, work) .enqueueUniqueWork(BACKGROUND_WORKER_NAME, ExistingWorkPolicy.REPLACE, work)
@@ -6,29 +6,17 @@ import androidx.work.Worker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
class MediaObserver(context: Context, params: WorkerParameters) : Worker(context, params) { 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 { override fun doWork(): Result {
Log.i("MediaObserver", "Content change detected, starting background worker") Log.i("MediaObserver", "Content change detected, starting background worker")
// Re-enqueue itself to listen for future changes
BackgroundWorkerApiImpl.enqueueMediaObserver(ctx)
// Enqueue backup worker only if there are new media changes // Enqueue backup worker only if there are new media changes
if (triggeredContentUris.isNotEmpty()) { if (triggeredContentUris.isNotEmpty()) {
val type = BackgroundWorkerApiImpl.enqueueBackgroundWorker(ctx)
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()
}
} }
+1 -1
View File
@@ -24,7 +24,7 @@ import UIKit
BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!) BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!)
BackgroundServicePlugin.registerBackgroundProcessing() BackgroundServicePlugin.registerBackgroundProcessing()
BackgroundWorkerApiImpl.registerBackgroundProcessing() BackgroundWorkerApiImpl.registerBackgroundWorkers()
BackgroundServicePlugin.setPluginRegistrantCallback { registry in BackgroundServicePlugin.setPluginRegistrantCallback { registry in
if !registry.hasPlugin("org.cocoapods.path-provider-foundation") { if !registry.hasPlugin("org.cocoapods.path-provider-foundation") {
@@ -73,9 +73,8 @@ class BackgroundWorkerPigeonCodec: FlutterStandardMessageCodec, @unchecked Senda
/// Generated protocol from Pigeon that represents a handler of messages from Flutter. /// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol BackgroundWorkerFgHostApi { protocol BackgroundWorkerFgHostApi {
func enableSyncWorker() throws func enable() throws
func enableUploadWorker(callbackHandle: Int64) throws func disable() throws
func disableUploadWorker() throws
} }
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -84,52 +83,38 @@ class BackgroundWorkerFgHostApiSetup {
/// Sets up an instance of `BackgroundWorkerFgHostApi` to handle messages through the `binaryMessenger`. /// Sets up an instance of `BackgroundWorkerFgHostApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: BackgroundWorkerFgHostApi?, messageChannelSuffix: String = "") { static func setUp(binaryMessenger: FlutterBinaryMessenger, api: BackgroundWorkerFgHostApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
let enableSyncWorkerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableSyncWorker\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) let enableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api { if let api = api {
enableSyncWorkerChannel.setMessageHandler { _, reply in enableChannel.setMessageHandler { _, reply in
do { do {
try api.enableSyncWorker() try api.enable()
reply(wrapResult(nil)) reply(wrapResult(nil))
} catch { } catch {
reply(wrapError(error)) reply(wrapError(error))
} }
} }
} else { } else {
enableSyncWorkerChannel.setMessageHandler(nil) enableChannel.setMessageHandler(nil)
} }
let enableUploadWorkerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) let disableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api { if let api = api {
enableUploadWorkerChannel.setMessageHandler { message, reply in disableChannel.setMessageHandler { _, reply in
let args = message as! [Any?]
let callbackHandleArg = args[0] as! Int64
do { do {
try api.enableUploadWorker(callbackHandle: callbackHandleArg) try api.disable()
reply(wrapResult(nil)) reply(wrapResult(nil))
} catch { } catch {
reply(wrapError(error)) reply(wrapError(error))
} }
} }
} else { } else {
enableUploadWorkerChannel.setMessageHandler(nil) disableChannel.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. /// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol BackgroundWorkerBgHostApi { protocol BackgroundWorkerBgHostApi {
func onInitialized() throws func onInitialized() throws
func close() throws
} }
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -151,11 +136,23 @@ class BackgroundWorkerBgHostApiSetup {
} else { } else {
onInitializedChannel.setMessageHandler(nil) 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. /// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift.
protocol BackgroundWorkerFlutterApiProtocol { 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 onIosUpload(isRefresh isRefreshArg: Bool, maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result<Void, PigeonError>) -> Void)
func onAndroidUpload(completion: @escaping (Result<Void, PigeonError>) -> Void) func onAndroidUpload(completion: @escaping (Result<Void, PigeonError>) -> Void)
func cancel(completion: @escaping (Result<Void, PigeonError>) -> Void) func cancel(completion: @escaping (Result<Void, PigeonError>) -> Void)
@@ -170,24 +167,6 @@ class BackgroundWorkerFlutterApi: BackgroundWorkerFlutterApiProtocol {
var codec: BackgroundWorkerPigeonCodec { var codec: BackgroundWorkerPigeonCodec {
return BackgroundWorkerPigeonCodec.shared 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) { 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 channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload\(messageChannelSuffix)"
let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec)
@@ -1,7 +1,7 @@
import BackgroundTasks import BackgroundTasks
import Flutter import Flutter
enum BackgroundTaskType { case localSync, refreshUpload, processingUpload } enum BackgroundTaskType { case refresh, processing }
/* /*
* DEBUG: Testing Background Tasks in Xcode * DEBUG: Testing Background Tasks in Xcode
@@ -9,10 +9,6 @@ enum BackgroundTaskType { case localSync, refreshUpload, processingUpload }
* To test background task functionality during development: * To test background task functionality during development:
* 1. Pause the application in Xcode debugger * 1. Pause the application in Xcode debugger
* 2. In the debugger console, enter one of the following commands: * 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): ## For background refresh (short-running sync):
@@ -24,8 +20,6 @@ enum BackgroundTaskType { case localSync, refreshUpload, processingUpload }
* To simulate task expiration (useful for testing expiration handlers): * 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.refreshUpload"]
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"app.alextran.immich.background.processingUpload"] e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"app.alextran.immich.background.processingUpload"]
@@ -86,28 +80,10 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
* starts the engine, and sets up a timeout timer if specified. * starts the engine, and sets up a timeout timer if specified.
*/ */
func run() { 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 // Start the Flutter engine with the specified callback as the entry point
let isRunning = engine.run( let isRunning = engine.run(
withEntrypoint: callback.callbackName, withEntrypoint: "backgroundSyncNativeEntrypoint",
libraryURI: callback.callbackLibraryPath libraryURI: "package:immich_mobile/domain/services/background_worker.service.dart"
) )
// Verify that the Flutter engine started successfully // Verify that the Flutter engine started successfully
@@ -127,7 +103,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
if maxSeconds != nil { if maxSeconds != nil {
// Schedule a timer to cancel the task after the specified timeout period // Schedule a timer to cancel the task after the specified timeout period
Timer.scheduledTimer(withTimeInterval: TimeInterval(maxSeconds!), repeats: false) { _ in Timer.scheduledTimer(withTimeInterval: TimeInterval(maxSeconds!), repeats: false) { _ in
self.cancel() self.close()
} }
} }
} }
@@ -138,17 +114,9 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
* This method acts as a bridge between the native iOS background task system and Flutter. * This method acts as a bridge between the native iOS background task system and Flutter.
*/ */
func onInitialized() throws { func onInitialized() throws {
switch self.taskType { flutterApi?.onIosUpload(isRefresh: self.taskType == .refresh, maxSeconds: maxSeconds.map { Int64($0) }, completion: { result in
case .refreshUpload, .processingUpload: self.handleHostResult(result: result)
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)
})
}
} }
/** /**
@@ -156,7 +124,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
* Sends a cancel signal to the Flutter side and sets up a fallback timer to ensure * 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. * the completion handler is eventually called even if Flutter doesn't respond.
*/ */
func cancel() { func close() {
if isComplete { if isComplete {
return return
} }
@@ -182,7 +150,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
private func handleHostResult(result: Result<Void, PigeonError>) { private func handleHostResult(result: Result<Void, PigeonError>) {
switch result { switch result {
case .success(): self.complete(success: true) case .success(): self.complete(success: true)
case .failure(_): self.cancel() case .failure(_): self.close()
} }
} }
@@ -195,6 +163,10 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
* - Parameter success: Indicates whether the background task completed successfully * - Parameter success: Indicates whether the background task completed successfully
*/ */
private func complete(success: Bool) { private func complete(success: Bool) {
if(isComplete) {
return
}
isComplete = true isComplete = true
engine.destroyContext() engine.destroyContext()
completionHandler(success) completionHandler(success)
@@ -1,84 +1,40 @@
import BackgroundTasks import BackgroundTasks
class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi { class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
func enableSyncWorker() throws {
BackgroundWorkerApiImpl.scheduleLocalSync() func enable() throws {
print("BackgroundUploadImpl:enableSyncWorker Local Sync worker scheduled") BackgroundWorkerApiImpl.scheduleRefreshWorker()
BackgroundWorkerApiImpl.scheduleProcessingWorker()
print("BackgroundUploadImpl:enbale Background worker scheduled")
} }
func enableUploadWorker(callbackHandle: Int64) throws { func disable() throws {
BackgroundWorkerApiImpl.updateUploadEnabled(true) BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.refreshTaskID);
// Store the callback handle for later use when starting background Flutter isolates BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.processingTaskID);
BackgroundWorkerApiImpl.updateUploadCallbackHandle(callbackHandle) print("BackgroundUploadImpl:disableUploadWorker Disabled background workers")
BackgroundWorkerApiImpl.scheduleRefreshUpload()
BackgroundWorkerApiImpl.scheduleProcessingUpload()
print("BackgroundUploadImpl:enableUploadWorker Scheduled background upload tasks")
} }
func disableUploadWorker() throws { private static let refreshTaskID = "app.alextran.immich.background.refreshUpload"
BackgroundWorkerApiImpl.updateUploadEnabled(false) private static let processingTaskID = "app.alextran.immich.background.processingUpload"
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"
private static func updateUploadEnabled(_ isEnabled: Bool) { public static func registerBackgroundWorkers() {
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( BGTaskScheduler.shared.register(
forTaskWithIdentifier: processingUploadTaskID, using: nil) { task in forTaskWithIdentifier: processingTaskID, using: nil) { task in
if task is BGProcessingTask { if task is BGProcessingTask {
handleBackgroundProcessing(task: task as! BGProcessingTask) handleBackgroundProcessing(task: task as! BGProcessingTask)
} }
} }
BGTaskScheduler.shared.register( BGTaskScheduler.shared.register(
forTaskWithIdentifier: refreshUploadTaskID, using: nil) { task in forTaskWithIdentifier: refreshTaskID, using: nil) { task in
if task is BGAppRefreshTask { if task is BGAppRefreshTask {
handleBackgroundRefresh(task: task as! BGAppRefreshTask, taskType: .refreshUpload) handleBackgroundRefresh(task: task as! BGAppRefreshTask)
} }
} }
BGTaskScheduler.shared.register(
forTaskWithIdentifier: localSyncTaskID, using: nil) { task in
if task is BGAppRefreshTask {
handleBackgroundRefresh(task: task as! BGAppRefreshTask, taskType: .localSync)
}
}
} }
private static func scheduleLocalSync() { private static func scheduleRefreshWorker() {
let backgroundRefresh = BGAppRefreshTaskRequest(identifier: localSyncTaskID) let backgroundRefresh = BGAppRefreshTaskRequest(identifier: refreshTaskID)
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 scheduleRefreshUpload() {
let backgroundRefresh = BGAppRefreshTaskRequest(identifier: refreshUploadTaskID)
backgroundRefresh.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60) // 5 mins backgroundRefresh.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60) // 5 mins
do { do {
@@ -88,8 +44,8 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
} }
} }
private static func scheduleProcessingUpload() { private static func scheduleProcessingWorker() {
let backgroundProcessing = BGProcessingTaskRequest(identifier: processingUploadTaskID) let backgroundProcessing = BGProcessingTaskRequest(identifier: processingTaskID)
backgroundProcessing.requiresNetworkConnectivity = true backgroundProcessing.requiresNetworkConnectivity = true
backgroundProcessing.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 mins backgroundProcessing.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 mins
@@ -101,16 +57,16 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
} }
} }
private static func handleBackgroundRefresh(task: BGAppRefreshTask, taskType: BackgroundTaskType) { private static func handleBackgroundRefresh(task: BGAppRefreshTask) {
scheduleRefreshUpload() scheduleRefreshWorker()
// Restrict the refresh task to run only for a maximum of 20 seconds // Restrict the refresh task to run only for a maximum of (maxSeconds) seconds
runBackgroundWorker(task: task, taskType: taskType, maxSeconds: 20) runBackgroundWorker(task: task, taskType: .refresh, maxSeconds: 20)
} }
private static func handleBackgroundProcessing(task: BGProcessingTask) { private static func handleBackgroundProcessing(task: BGProcessingTask) {
scheduleProcessingUpload() scheduleProcessingWorker()
// There are no restrictions for processing tasks. Although, the OS could signal expiration at any time // There are no restrictions for processing tasks. Although, the OS could signal expiration at any time
runBackgroundWorker(task: task, taskType: .processingUpload, maxSeconds: nil) runBackgroundWorker(task: task, taskType: .processing, maxSeconds: nil)
} }
/** /**
@@ -134,7 +90,7 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
task.expirationHandler = { task.expirationHandler = {
DispatchQueue.main.async { DispatchQueue.main.async {
backgroundWorker.cancel() backgroundWorker.close()
} }
isSuccess = false isSuccess = false
+185 -186
View File
@@ -1,190 +1,189 @@
<?xml version="1.0" encoding="UTF-8"?> <?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"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>AppGroupId</key> <key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string> <string>$(CUSTOM_GROUP_ID)</string>
<key>BGTaskSchedulerPermittedIdentifiers</key> <key>BGTaskSchedulerPermittedIdentifiers</key>
<array> <array>
<string>app.alextran.immich.background.localSync</string> <string>app.alextran.immich.background.refreshUpload</string>
<string>app.alextran.immich.background.refreshUpload</string> <string>app.alextran.immich.background.processingUpload</string>
<string>app.alextran.immich.background.processingUpload</string> <string>app.alextran.immich.backgroundFetch</string>
<string>app.alextran.immich.backgroundFetch</string> <string>app.alextran.immich.backgroundProcessing</string>
<string>app.alextran.immich.backgroundProcessing</string> </array>
</array> <key>CADisableMinimumFrameDurationOnPhone</key>
<key>CADisableMinimumFrameDurationOnPhone</key> <true/>
<true /> <key>CFBundleDevelopmentRegion</key>
<key>CFBundleDevelopmentRegion</key> <string>$(DEVELOPMENT_LANGUAGE)</string>
<string>$(DEVELOPMENT_LANGUAGE)</string> <key>CFBundleDisplayName</key>
<key>CFBundleDisplayName</key> <string>${PRODUCT_NAME}</string>
<string>${PRODUCT_NAME}</string> <key>CFBundleDocumentTypes</key>
<key>CFBundleDocumentTypes</key> <array>
<array> <dict>
<dict> <key>CFBundleTypeName</key>
<key>CFBundleTypeName</key> <string>ShareHandler</string>
<string>ShareHandler</string> <key>LSHandlerRank</key>
<key>LSHandlerRank</key> <string>Alternate</string>
<string>Alternate</string> <key>LSItemContentTypes</key>
<key>LSItemContentTypes</key> <array>
<array> <string>public.file-url</string>
<string>public.file-url</string> <string>public.image</string>
<string>public.image</string> <string>public.text</string>
<string>public.text</string> <string>public.movie</string>
<string>public.movie</string> <string>public.url</string>
<string>public.url</string> <string>public.data</string>
<string>public.data</string> </array>
</array> </dict>
</dict> </array>
</array> <key>CFBundleExecutable</key>
<key>CFBundleExecutable</key> <string>$(EXECUTABLE_NAME)</string>
<string>$(EXECUTABLE_NAME)</string> <key>CFBundleIdentifier</key>
<key>CFBundleIdentifier</key> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <key>CFBundleInfoDictionaryVersion</key>
<key>CFBundleInfoDictionaryVersion</key> <string>6.0</string>
<string>6.0</string> <key>CFBundleLocalizations</key>
<key>CFBundleLocalizations</key> <array>
<array> <string>en</string>
<string>en</string> <string>ar</string>
<string>ar</string> <string>ca</string>
<string>ca</string> <string>cs</string>
<string>cs</string> <string>da</string>
<string>da</string> <string>de</string>
<string>de</string> <string>es</string>
<string>es</string> <string>fi</string>
<string>fi</string> <string>fr</string>
<string>fr</string> <string>he</string>
<string>he</string> <string>hi</string>
<string>hi</string> <string>hu</string>
<string>hu</string> <string>it</string>
<string>it</string> <string>ja</string>
<string>ja</string> <string>ko</string>
<string>ko</string> <string>lv</string>
<string>lv</string> <string>mn</string>
<string>mn</string> <string>nb</string>
<string>nb</string> <string>nl</string>
<string>nl</string> <string>pl</string>
<string>pl</string> <string>pt</string>
<string>pt</string> <string>ro</string>
<string>ro</string> <string>ru</string>
<string>ru</string> <string>sk</string>
<string>sk</string> <string>sl</string>
<string>sl</string> <string>sr</string>
<string>sr</string> <string>sv</string>
<string>sv</string> <string>th</string>
<string>th</string> <string>uk</string>
<string>uk</string> <string>vi</string>
<string>vi</string> <string>zh</string>
<string>zh</string> </array>
</array> <key>CFBundleName</key>
<key>CFBundleName</key> <string>immich_mobile</string>
<string>immich_mobile</string> <key>CFBundlePackageType</key>
<key>CFBundlePackageType</key> <string>APPL</string>
<string>APPL</string> <key>CFBundleShortVersionString</key>
<key>CFBundleShortVersionString</key> <string>1.140.0</string>
<string>1.140.0</string> <key>CFBundleSignature</key>
<key>CFBundleSignature</key> <string>????</string>
<string>????</string> <key>CFBundleURLTypes</key>
<key>CFBundleURLTypes</key> <array>
<array> <dict>
<dict> <key>CFBundleTypeRole</key>
<key>CFBundleTypeRole</key> <string>Editor</string>
<string>Editor</string> <key>CFBundleURLName</key>
<key>CFBundleURLName</key> <string>Share Extension</string>
<string>Share Extension</string> <key>CFBundleURLSchemes</key>
<key>CFBundleURLSchemes</key> <array>
<array> <string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string> </array>
</array> </dict>
</dict> <dict>
<dict> <key>CFBundleTypeRole</key>
<key>CFBundleTypeRole</key> <string>Editor</string>
<string>Editor</string> <key>CFBundleURLName</key>
<key>CFBundleURLName</key> <string>Deep Link</string>
<string>Deep Link</string> <key>CFBundleURLSchemes</key>
<key>CFBundleURLSchemes</key> <array>
<array> <string>immich</string>
<string>immich</string> </array>
</array> </dict>
</dict> </array>
</array> <key>CFBundleVersion</key>
<key>CFBundleVersion</key> <string>219</string>
<string>219</string> <key>FLTEnableImpeller</key>
<key>FLTEnableImpeller</key> <true/>
<true /> <key>ITSAppUsesNonExemptEncryption</key>
<key>ITSAppUsesNonExemptEncryption</key> <false/>
<false /> <key>LSApplicationQueriesSchemes</key>
<key>LSApplicationQueriesSchemes</key> <array>
<array> <string>https</string>
<string>https</string> </array>
</array> <key>LSRequiresIPhoneOS</key>
<key>LSRequiresIPhoneOS</key> <true/>
<true /> <key>LSSupportsOpeningDocumentsInPlace</key>
<key>LSSupportsOpeningDocumentsInPlace</key> <string>No</string>
<string>No</string> <key>MGLMapboxMetricsEnabledSettingShownInApp</key>
<key>MGLMapboxMetricsEnabledSettingShownInApp</key> <true/>
<true /> <key>NSAppTransportSecurity</key>
<key>NSAppTransportSecurity</key> <dict>
<dict> <key>NSAllowsArbitraryLoads</key>
<key>NSAllowsArbitraryLoads</key> <true/>
<true /> </dict>
</dict> <key>NSBonjourServices</key>
<key>NSBonjourServices</key> <array>
<array> <string>_googlecast._tcp</string>
<string>_googlecast._tcp</string> <string>_CC1AD845._googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string> </array>
</array> <key>NSCameraUsageDescription</key>
<key>NSCameraUsageDescription</key> <string>We need to access the camera to let you take beautiful video using this app</string>
<string>We need to access the camera to let you take beautiful video using this app</string> <key>NSFaceIDUsageDescription</key>
<key>NSFaceIDUsageDescription</key> <string>We need to use FaceID to allow access to your locked folder</string>
<string>We need to use FaceID to allow access to your locked folder</string> <key>NSLocalNetworkUsageDescription</key>
<key>NSLocalNetworkUsageDescription</key> <string>We need local network permission to connect to the local server using IP address and
<string>We need local network permission to connect to the local server using IP address and
allow the casting feature to work</string> allow the casting feature to work</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key> <key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name for background upload mechanism</string> <string>We require this permission to access the local WiFi name for background upload mechanism</string>
<key>NSLocationUsageDescription</key> <key>NSLocationUsageDescription</key>
<string>We require this permission to access the local WiFi name</string> <string>We require this permission to access the local WiFi name</string>
<key>NSLocationWhenInUseUsageDescription</key> <key>NSLocationWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name</string> <string>We require this permission to access the local WiFi name</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>We need to access the microphone to let you take beautiful video using this app</string> <string>We need to access the microphone to let you take beautiful video using this app</string>
<key>NSPhotoLibraryAddUsageDescription</key> <key>NSPhotoLibraryAddUsageDescription</key>
<string>We need to manage backup your photos album</string> <string>We need to manage backup your photos album</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>We need to manage backup your photos album</string> <string>We need to manage backup your photos album</string>
<key>NSUserActivityTypes</key> <key>NSUserActivityTypes</key>
<array> <array>
<string>INSendMessageIntent</string> <string>INSendMessageIntent</string>
</array> </array>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true /> <true/>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>fetch</string> <string>fetch</string>
<string>processing</string> <string>processing</string>
</array> </array>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>
<string>Main</string> <string>Main</string>
<key>UIStatusBarHidden</key> <key>UIStatusBarHidden</key>
<false /> <false/>
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UISupportedInterfaceOrientations~ipad</key> <key>UISupportedInterfaceOrientations~ipad</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string> <string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<true /> <true/>
<key>io.flutter.embedded_views_preview</key> <key>io.flutter.embedded_views_preview</key>
<true /> <true/>
</dict> </dict>
</plist> </plist>
@@ -14,6 +14,7 @@ import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/user.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/app_settings.service.dart';
import 'package:immich_mobile/services/auth.service.dart'; import 'package:immich_mobile/services/auth.service.dart';
import 'package:immich_mobile/services/localization.service.dart'; import 'package:immich_mobile/services/localization.service.dart';
@@ -29,13 +30,9 @@ class BackgroundWorkerFgService {
const BackgroundWorkerFgService(this._foregroundHostApi); const BackgroundWorkerFgService(this._foregroundHostApi);
// TODO: Move this call to native side once old timeline is removed // TODO: Move this call to native side once old timeline is removed
Future<void> enableSyncService() => _foregroundHostApi.enableSyncWorker(); Future<void> enable() => _foregroundHostApi.enable();
Future<void> enableUploadService() => _foregroundHostApi.enableUploadWorker( Future<void> disable() => _foregroundHostApi.disable();
PluginUtilities.getCallbackHandle(_backgroundSyncNativeEntrypoint)!.toRawHandle(),
);
Future<void> disableUploadService() => _foregroundHostApi.disableUploadWorker();
} }
class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
@@ -44,7 +41,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
final Drift _drift; final Drift _drift;
final DriftLogger _driftLogger; final DriftLogger _driftLogger;
final BackgroundWorkerBgHostApi _backgroundHostApi; final BackgroundWorkerBgHostApi _backgroundHostApi;
final Logger _logger = Logger('BackgroundUploadBgService'); final Logger _logger = Logger('BackgroundWorkerBgService');
bool _isCleanedUp = false; bool _isCleanedUp = false;
@@ -66,92 +63,85 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
bool get _isBackupEnabled => _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup); bool get _isBackupEnabled => _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
Future<void> init() async { Future<void> init() async {
await loadTranslations(); try {
HttpSSLOptions.apply(applyNative: false); await loadTranslations();
await _ref.read(authServiceProvider).setOpenApiServiceEndpoint(); HttpSSLOptions.apply(applyNative: false);
await _ref.read(authServiceProvider).setOpenApiServiceEndpoint();
// Initialize the file downloader // Initialize the file downloader
await FileDownloader().configure( await FileDownloader().configure(
globalConfig: [ globalConfig: [
// maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3 // maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3
(Config.holdingQueue, (6, 6, 3)), (Config.holdingQueue, (6, 6, 3)),
// On Android, if files are larger than 256MB, run in foreground service // On Android, if files are larger than 256MB, run in foreground service
(Config.runInForegroundIfFileLargerThan, 256), (Config.runInForegroundIfFileLargerThan, 256),
], ],
); );
await FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false); await FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false);
await FileDownloader().trackTasks(); await FileDownloader().trackTasks();
configureFileDownloaderNotifications(); configureFileDownloaderNotifications();
// Notify the host that the background upload service has been initialized and is ready to use await _ref.read(fileMediaRepositoryProvider).enableBackgroundAccess();
await _backgroundHostApi.onInitialized();
// 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();
}
} }
@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 @override
Future<void> onAndroidUpload() async { Future<void> onAndroidUpload() async {
_logger.info('Android background processing started'); try {
final sw = Stopwatch()..start(); _logger.info('Android background processing started');
final sw = Stopwatch()..start();
await _syncAssets(hashTimeout: Duration(minutes: _isBackupEnabled ? 3 : 6)); await _syncAssets(hashTimeout: Duration(minutes: _isBackupEnabled ? 3 : 6));
await _handleBackup(processBulk: false); await _handleBackup(processBulk: false);
await _cleanup(); sw.stop();
_logger.info("Android background processing completed in ${sw.elapsed.inSeconds}s");
sw.stop(); } catch (error, stack) {
_logger.info("Android background processing completed in ${sw.elapsed.inSeconds}s"); _logger.severe("Failed to complete Android background processing", error, stack);
} finally {
await _cleanup();
}
} }
/* 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 @override
Future<void> onIosUpload(bool isRefresh, int? maxSeconds) async { Future<void> onIosUpload(bool isRefresh, int? maxSeconds) async {
_logger.info('iOS background upload started with maxSeconds: ${maxSeconds}s'); try {
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); final timeout = isRefresh ? const Duration(seconds: 5) : Duration(minutes: _isBackupEnabled ? 3 : 6);
await _syncAssets(hashTimeout: timeout); await _syncAssets(hashTimeout: timeout);
final backupFuture = _handleBackup(); final backupFuture = _handleBackup();
if (maxSeconds != null) { if (maxSeconds != null) {
await backupFuture.timeout(Duration(seconds: maxSeconds - 1), onTimeout: () {}); await backupFuture.timeout(Duration(seconds: maxSeconds - 1), onTimeout: () {});
} else { } else {
await backupFuture; 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();
} }
await _cleanup();
sw.stop();
_logger.info("iOS background upload completed in ${sw.elapsed.inSeconds}s");
} }
@override @override
Future<void> cancel() async { Future<void> cancel() async {
_logger.warning("Background upload cancelled"); _logger.warning("Background worker cancelled");
await _cleanup(); try {
await _cleanup();
} catch (error, stack) {
debugPrint('Failed to cleanup background worker: $error with stack: $stack');
}
} }
Future<void> _cleanup() async { Future<void> _cleanup() async {
@@ -159,13 +149,21 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
return; return;
} }
_isCleanedUp = true; try {
await _ref.read(backgroundSyncProvider).cancel(); _isCleanedUp = true;
await _ref.read(backgroundSyncProvider).cancelLocal(); _logger.info("Cleaning up background worker");
await _isar.close(); await _ref.read(backgroundSyncProvider).cancel();
await _drift.close(); await _ref.read(backgroundSyncProvider).cancelLocal();
await _driftLogger.close(); if (_isar.isOpen) {
_ref.dispose(); 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');
}
} }
Future<void> _handleBackup({bool processBulk = true}) async { Future<void> _handleBackup({bool processBulk = true}) async {
@@ -190,7 +188,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
} }
} }
Future<void> _syncAssets({Duration? hashTimeout, bool syncRemote = true}) async { Future<void> _syncAssets({Duration? hashTimeout}) async {
final futures = <Future<void>>[]; final futures = <Future<void>>[];
final localSyncFuture = _ref.read(backgroundSyncProvider).syncLocal().then((_) async { final localSyncFuture = _ref.read(backgroundSyncProvider).syncLocal().then((_) async {
@@ -212,17 +210,16 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
}); });
futures.add(localSyncFuture); futures.add(localSyncFuture);
if (syncRemote) { futures.add(_ref.read(backgroundSyncProvider).syncRemote());
final remoteSyncFuture = _ref.read(backgroundSyncProvider).syncRemote();
futures.add(remoteSyncFuture);
}
await Future.wait(futures); 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') @pragma('vm:entry-point')
Future<void> _backgroundSyncNativeEntrypoint() async { Future<void> backgroundSyncNativeEntrypoint() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
DartPluginRegistrant.ensureInitialized(); DartPluginRegistrant.ensureInitialized();
+13 -6
View File
@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:immich_mobile/constants/constants.dart'; 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/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
@@ -35,6 +36,7 @@ class HashService {
bool get isCancelled => _cancelChecker?.call() ?? false; bool get isCancelled => _cancelChecker?.call() ?? false;
Future<void> hashAssets() async { Future<void> hashAssets() async {
_log.info("Starting hashing of assets");
final Stopwatch stopwatch = Stopwatch()..start(); final Stopwatch stopwatch = Stopwatch()..start();
// Sorted by backupSelection followed by isCloud // Sorted by backupSelection followed by isCloud
final localAlbums = await _localAlbumRepository.getAll( final localAlbums = await _localAlbumRepository.getAll(
@@ -49,7 +51,7 @@ class HashService {
final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id); final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
if (assetsToHash.isNotEmpty) { if (assetsToHash.isNotEmpty) {
await _hashAssets(assetsToHash); await _hashAssets(album, assetsToHash);
} }
} }
@@ -60,7 +62,7 @@ class HashService {
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB /// 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 /// 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. /// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
Future<void> _hashAssets(List<LocalAsset> assetsToHash) async { Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash) async {
int bytesProcessed = 0; int bytesProcessed = 0;
final toHash = <_AssetToPath>[]; final toHash = <_AssetToPath>[];
@@ -72,6 +74,9 @@ class HashService {
final file = await _storageRepository.getFileForAsset(asset.id); final file = await _storageRepository.getFileForAsset(asset.id);
if (file == null) { if (file == null) {
_log.warning(
"Cannot get file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt} from album: ${album.name}",
);
continue; continue;
} }
@@ -79,17 +84,17 @@ class HashService {
toHash.add(_AssetToPath(asset: asset, path: file.path)); toHash.add(_AssetToPath(asset: asset, path: file.path));
if (toHash.length >= batchFileLimit || bytesProcessed >= batchSizeLimit) { if (toHash.length >= batchFileLimit || bytesProcessed >= batchSizeLimit) {
await _processBatch(toHash); await _processBatch(album, toHash);
toHash.clear(); toHash.clear();
bytesProcessed = 0; bytesProcessed = 0;
} }
} }
await _processBatch(toHash); await _processBatch(album, toHash);
} }
/// Processes a batch of assets. /// Processes a batch of assets.
Future<void> _processBatch(List<_AssetToPath> toHash) async { Future<void> _processBatch(LocalAlbum album, List<_AssetToPath> toHash) async {
if (toHash.isEmpty) { if (toHash.isEmpty) {
return; return;
} }
@@ -114,7 +119,9 @@ class HashService {
if (hash?.length == 20) { if (hash?.length == 20) {
hashed.add(asset.copyWith(checksum: base64.encode(hash!))); hashed.add(asset.copyWith(checksum: base64.encode(hash!)));
} else { } else {
_log.warning("Failed to hash file for ${asset.id}: ${asset.name} created at ${asset.createdAt}"); _log.warning(
"Failed to hash file for ${asset.id}: ${asset.name} created at ${asset.createdAt} from album: ${album.name}",
);
} }
} }
@@ -6,6 +6,7 @@ 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/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.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/platform/native_sync_api.g.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:platform/platform.dart'; import 'package:platform/platform.dart';
@@ -285,7 +286,7 @@ extension on Iterable<PlatformAlbum> {
(e) => LocalAlbum( (e) => LocalAlbum(
id: e.id, id: e.id,
name: e.name, name: e.name,
updatedAt: e.updatedAt == null ? DateTime.now() : DateTime.fromMillisecondsSinceEpoch(e.updatedAt! * 1000), updatedAt: tryFromSecondsSinceEpoch(e.updatedAt) ?? DateTime.now(),
assetCount: e.assetCount, assetCount: e.assetCount,
), ),
).toList(); ).toList();
@@ -300,8 +301,8 @@ extension on Iterable<PlatformAsset> {
name: e.name, name: e.name,
checksum: null, checksum: null,
type: AssetType.values.elementAtOrNull(e.type) ?? AssetType.other, type: AssetType.values.elementAtOrNull(e.type) ?? AssetType.other,
createdAt: e.createdAt == null ? DateTime.now() : DateTime.fromMillisecondsSinceEpoch(e.createdAt! * 1000), createdAt: tryFromSecondsSinceEpoch(e.createdAt) ?? DateTime.now(),
updatedAt: e.updatedAt == null ? DateTime.now() : DateTime.fromMillisecondsSinceEpoch(e.updatedAt! * 1000), updatedAt: tryFromSecondsSinceEpoch(e.updatedAt) ?? DateTime.now(),
width: e.width, width: e.width,
height: e.height, height: e.height,
durationInSeconds: e.durationInSeconds, durationInSeconds: e.durationInSeconds,
@@ -16,6 +16,13 @@ class StorageRepository {
file = await entity?.originFile; file = await entity?.originFile;
if (file == null) { if (file == null) {
log.warning("Cannot get file for asset $assetId"); 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) { } catch (error, stackTrace) {
log.warning("Error getting file for asset $assetId", error, stackTrace); log.warning("Error getting file for asset $assetId", error, stackTrace);
@@ -34,6 +41,13 @@ class StorageRepository {
log.warning( log.warning(
"Cannot get motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}", "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) { } catch (error, stackTrace) {
log.warning( log.warning(
+3 -8
View File
@@ -16,7 +16,6 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart'; import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.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/asset_viewer/share_intent_upload.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart';
@@ -26,7 +25,6 @@ import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/theme.provider.dart'; import 'package:immich_mobile/providers/theme.provider.dart';
import 'package:immich_mobile/routing/app_navigation_observer.dart'; import 'package:immich_mobile/routing/app_navigation_observer.dart';
import 'package:immich_mobile/routing/router.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/background.service.dart';
import 'package:immich_mobile/services/deep_link.service.dart'; import 'package:immich_mobile/services/deep_link.service.dart';
import 'package:immich_mobile/services/local_notification.service.dart'; import 'package:immich_mobile/services/local_notification.service.dart';
@@ -206,14 +204,11 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
// needs to be delayed so that EasyLocalization is working // needs to be delayed so that EasyLocalization is working
if (Store.isBetaTimelineEnabled) { if (Store.isBetaTimelineEnabled) {
ref.read(driftBackgroundUploadFgService).enableSyncService(); ref.read(backgroundServiceProvider).disableService();
if (ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup)) { ref.read(driftBackgroundUploadFgService).enable();
ref.read(backgroundServiceProvider).disableService();
ref.read(driftBackgroundUploadFgService).enableUploadService();
}
} else { } else {
ref.read(driftBackgroundUploadFgService).disable();
ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
ref.read(driftBackgroundUploadFgService).disableUploadService();
} }
}); });
@@ -8,7 +8,6 @@ import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_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/presentation/widgets/backup/backup_toggle_button.widget.dart';
import 'package:immich_mobile/providers/background_sync.provider.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/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
@@ -43,12 +42,10 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
await ref.read(backgroundSyncProvider).syncRemote(); await ref.read(backgroundSyncProvider).syncRemote();
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id); await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
await ref.read(driftBackgroundUploadFgService).enableUploadService();
await ref.read(driftBackupProvider.notifier).startBackup(currentUser.id); await ref.read(driftBackupProvider.notifier).startBackup(currentUser.id);
} }
Future<void> stopBackup() async { Future<void> stopBackup() async {
await ref.read(driftBackgroundUploadFgService).disableUploadService();
await ref.read(driftBackupProvider.notifier).cancel(); await ref.read(driftBackupProvider.notifier).cancel();
} }
@@ -79,7 +79,7 @@ class _ChangeExperiencePageState extends ConsumerState<ChangeExperiencePage> {
ref.read(readonlyModeProvider.notifier).setReadonlyMode(false); ref.read(readonlyModeProvider.notifier).setReadonlyMode(false);
await migrateStoreToIsar(ref.read(isarProvider), ref.read(driftProvider)); await migrateStoreToIsar(ref.read(isarProvider), ref.read(driftProvider));
await ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); await ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
await ref.read(driftBackgroundUploadFgService).disableUploadService(); await ref.read(driftBackgroundUploadFgService).disable();
} }
await IsarStoreRepository(ref.read(isarProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta); await IsarStoreRepository(ref.read(isarProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta);
+27 -58
View File
@@ -59,9 +59,9 @@ class BackgroundWorkerFgHostApi {
final String pigeonVar_messageChannelSuffix; final String pigeonVar_messageChannelSuffix;
Future<void> enableSyncWorker() async { Future<void> enable() async {
final String pigeonVar_channelName = final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableSyncWorker$pigeonVar_messageChannelSuffix'; 'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>( final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
@@ -82,32 +82,9 @@ class BackgroundWorkerFgHostApi {
} }
} }
Future<void> enableUploadWorker(int callbackHandle) async { Future<void> disable() async {
final String pigeonVar_channelName = final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker$pigeonVar_messageChannelSuffix'; 'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$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?>( final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
@@ -164,13 +141,34 @@ class BackgroundWorkerBgHostApi {
return; 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 { abstract class BackgroundWorkerFlutterApi {
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec(); static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
Future<void> onLocalSync(int? maxSeconds);
Future<void> onIosUpload(bool isRefresh, int? maxSeconds); Future<void> onIosUpload(bool isRefresh, int? maxSeconds);
Future<void> onAndroidUpload(); Future<void> onAndroidUpload();
@@ -183,35 +181,6 @@ abstract class BackgroundWorkerFlutterApi {
String messageChannelSuffix = '', String messageChannelSuffix = '',
}) { }) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$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?>( final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload$messageChannelSuffix', 'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload$messageChannelSuffix',
+19
View File
@@ -0,0 +1,19 @@
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;
}
}
+2 -2
View File
@@ -18,7 +18,7 @@ class AssetsApi {
/// checkBulkUpload /// checkBulkUpload
/// ///
/// Checks if assets exist by checksums /// Checks if assets exist by checksums. This endpoint requires the `asset.upload` permission.
/// ///
/// Note: This method returns the HTTP [Response]. /// Note: This method returns the HTTP [Response].
/// ///
@@ -52,7 +52,7 @@ class AssetsApi {
/// checkBulkUpload /// checkBulkUpload
/// ///
/// Checks if assets exist by checksums /// Checks if assets exist by checksums. This endpoint requires the `asset.upload` permission.
/// ///
/// Parameters: /// Parameters:
/// ///
+4 -10
View File
@@ -13,13 +13,9 @@ import 'package:pigeon/pigeon.dart';
) )
@HostApi() @HostApi()
abstract class BackgroundWorkerFgHostApi { abstract class BackgroundWorkerFgHostApi {
void enableSyncWorker(); void enable();
// Enables the background upload service with the given callback handle void disable();
void enableUploadWorker(int callbackHandle);
// Disables the background upload service
void disableUploadWorker();
} }
@HostApi() @HostApi()
@@ -27,14 +23,12 @@ abstract class BackgroundWorkerBgHostApi {
// Called from the background flutter engine when it has bootstrapped and established the // 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 // required platform channels to notify the native side to start the background upload
void onInitialized(); void onInitialized();
void close();
} }
@FlutterApi() @FlutterApi()
abstract class BackgroundWorkerFlutterApi { 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 // iOS Only: Called when the iOS background upload is triggered
@async @async
void onIosUpload(bool isRefresh, int? maxSeconds); void onIosUpload(bool isRefresh, int? maxSeconds);
@@ -0,0 +1,58 @@
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);
});
});
}
+3 -2
View File
@@ -1855,7 +1855,7 @@
}, },
"/assets/bulk-upload-check": { "/assets/bulk-upload-check": {
"post": { "post": {
"description": "Checks if assets exist by checksums", "description": "Checks if assets exist by checksums. This endpoint requires the `asset.upload` permission.",
"operationId": "checkBulkUpload", "operationId": "checkBulkUpload",
"parameters": [], "parameters": [],
"requestBody": { "requestBody": {
@@ -1894,7 +1894,8 @@
"summary": "checkBulkUpload", "summary": "checkBulkUpload",
"tags": [ "tags": [
"Assets" "Assets"
] ],
"x-immich-permission": "asset.upload"
} }
}, },
"/assets/device/{deviceId}": { "/assets/device/{deviceId}": {
+3 -3
View File
@@ -1,5 +1,5 @@
# dev build # dev build
FROM ghcr.io/immich-app/base-server-dev:202508191104@sha256:0608857ef682099c458f0fb319afdcaf09462bbb5670b6dcd3642029f12eee1c AS dev FROM ghcr.io/immich-app/base-server-dev:202509021104@sha256:47d38c94775332000a93fbbeca1c796687b2d2919e3c75b6e26ab8a65d1864f3 AS dev
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \ CI=1 \
@@ -77,7 +77,7 @@ RUN apt-get update \
RUN dart --disable-analytics RUN dart --disable-analytics
# production-builder-base image # production-builder-base image
FROM ghcr.io/immich-app/base-server-dev:202508191104@sha256:0608857ef682099c458f0fb319afdcaf09462bbb5670b6dcd3642029f12eee1c AS prod-builder-base FROM ghcr.io/immich-app/base-server-dev:202509021104@sha256:47d38c94775332000a93fbbeca1c796687b2d2919e3c75b6e26ab8a65d1864f3 AS prod-builder-base
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \ CI=1 \
COREPACK_HOME=/tmp 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 pnpm --filter @immich/cli --prod --no-optional deploy /output/cli-pruned
# prod base image # prod base image
FROM ghcr.io/immich-app/base-server-prod:202508191104@sha256:4cce4119f5555fce5e383b681e4feea31956ceadb94cafcbcbbae2c7b94a1b62 FROM ghcr.io/immich-app/base-server-prod:202509021104@sha256:84f3727cff75c623f79236cdd9a2b72c84f7665057f474851016f702c67157af
WORKDIR /usr/src/app WORKDIR /usr/src/app
ENV NODE_ENV=production \ ENV NODE_ENV=production \
@@ -188,7 +188,7 @@ export class AssetMediaController {
* Checks if assets exist by checksums * Checks if assets exist by checksums
*/ */
@Post('bulk-upload-check') @Post('bulk-upload-check')
@Authenticated() @Authenticated({ permission: Permission.AssetUpload })
@ApiOperation({ @ApiOperation({
summary: 'checkBulkUpload', summary: 'checkBulkUpload',
description: 'Checks if assets exist by checksums', 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 { LoggingRepository } from 'src/repositories/logging.repository';
import { AssetMediaService } from 'src/services/asset-media.service'; import { AssetMediaService } from 'src/services/asset-media.service';
import { ImmichFile, UploadFile, UploadFiles } from 'src/types'; import { ImmichFile, UploadFile, UploadFiles } from 'src/types';
import { asRequest, mapToUploadFile } from 'src/utils/asset.util'; import { asUploadRequest, mapToUploadFile } from 'src/utils/asset.util';
export function getFile(files: UploadFiles, property: 'assetData' | 'sidecarData') { export function getFile(files: UploadFiles, property: 'assetData' | 'sidecarData') {
const file = files[property]?.[0]; const file = files[property]?.[0];
@@ -99,18 +99,21 @@ export class FileUploadInterceptor implements NestInterceptor {
} }
private fileFilter(request: AuthRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) { private fileFilter(request: AuthRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) {
return callbackify(() => this.assetService.canUploadFile(asRequest(request, file)), callback); return callbackify(() => this.assetService.canUploadFile(asUploadRequest(request, file)), callback);
} }
private filename(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) { private filename(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
return callbackify( return callbackify(
() => this.assetService.getUploadFilename(asRequest(request, file)), () => this.assetService.getUploadFilename(asUploadRequest(request, file)),
callback as Callback<string>, callback as Callback<string>,
); );
} }
private destination(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) { private destination(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
return callbackify(() => this.assetService.getUploadFolder(asRequest(request, file)), callback as Callback<string>); return callbackify(
() => this.assetService.getUploadFolder(asUploadRequest(request, file)),
callback as Callback<string>,
);
} }
private handleFile(request: AuthRequest, file: Express.Multer.File, callback: Callback<Partial<ImmichFile>>) { private handleFile(request: AuthRequest, file: Express.Multer.File, callback: Callback<Partial<ImmichFile>>) {
@@ -25,6 +25,7 @@ const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
const uploadFile = { const uploadFile = {
nullAuth: { nullAuth: {
auth: null, auth: null,
body: {},
fieldName: UploadFieldName.ASSET_DATA, fieldName: UploadFieldName.ASSET_DATA,
file: { file: {
uuid: 'random-uuid', uuid: 'random-uuid',
@@ -37,6 +38,7 @@ const uploadFile = {
filename: (fieldName: UploadFieldName, filename: string) => { filename: (fieldName: UploadFieldName, filename: string) => {
return { return {
auth: authStub.admin, auth: authStub.admin,
body: {},
fieldName, fieldName,
file: { file: {
uuid: 'random-uuid', uuid: 'random-uuid',
@@ -897,7 +899,10 @@ describe(AssetMediaService.name, () => {
describe('onUploadError', () => { describe('onUploadError', () => {
it('should queue a job to delete the uploaded file', async () => { it('should queue a job to delete the uploaded file', async () => {
const request = { user: authStub.user1 } as AuthRequest; const request = {
body: {},
user: authStub.user1,
} as AuthRequest;
const file = { const file = {
fieldname: UploadFieldName.ASSET_DATA, fieldname: UploadFieldName.ASSET_DATA,
+8 -14
View File
@@ -24,20 +24,14 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { AssetStatus, AssetType, AssetVisibility, CacheControl, JobName, Permission, StorageFolder } from 'src/enum'; import { AssetStatus, AssetType, AssetVisibility, CacheControl, JobName, Permission, StorageFolder } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard'; import { AuthRequest } from 'src/middleware/auth.guard';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { UploadFile } from 'src/types'; import { UploadFile, UploadRequest } from 'src/types';
import { requireUploadAccess } from 'src/utils/access'; import { requireUploadAccess } from 'src/utils/access';
import { asRequest, getAssetFiles, onBeforeLink } from 'src/utils/asset.util'; import { asUploadRequest, getAssetFiles, onBeforeLink } from 'src/utils/asset.util';
import { isAssetChecksumConstraint } from 'src/utils/database'; import { isAssetChecksumConstraint } from 'src/utils/database';
import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file'; import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types'; import { mimeTypes } from 'src/utils/mime-types';
import { fromChecksum } from 'src/utils/request'; import { fromChecksum } from 'src/utils/request';
interface UploadRequest {
auth: AuthDto | null;
fieldName: UploadFieldName;
file: UploadFile;
}
export interface AssetMediaRedirectResponse { export interface AssetMediaRedirectResponse {
targetSize: AssetMediaSize | 'original'; targetSize: AssetMediaSize | 'original';
} }
@@ -89,15 +83,15 @@ export class AssetMediaService extends BaseService {
throw new BadRequestException(`Unsupported file type ${filename}`); throw new BadRequestException(`Unsupported file type ${filename}`);
} }
getUploadFilename({ auth, fieldName, file }: UploadRequest): string { getUploadFilename({ auth, fieldName, file, body }: UploadRequest): string {
requireUploadAccess(auth); requireUploadAccess(auth);
const originalExtension = extname(file.originalName); const extension = extname(body.filename || file.originalName);
const lookup = { const lookup = {
[UploadFieldName.ASSET_DATA]: originalExtension, [UploadFieldName.ASSET_DATA]: extension,
[UploadFieldName.SIDECAR_DATA]: '.xmp', [UploadFieldName.SIDECAR_DATA]: '.xmp',
[UploadFieldName.PROFILE_DATA]: originalExtension, [UploadFieldName.PROFILE_DATA]: extension,
}; };
return sanitize(`${file.uuid}${lookup[fieldName]}`); return sanitize(`${file.uuid}${lookup[fieldName]}`);
@@ -117,8 +111,8 @@ export class AssetMediaService extends BaseService {
} }
async onUploadError(request: AuthRequest, file: Express.Multer.File) { async onUploadError(request: AuthRequest, file: Express.Multer.File) {
const uploadFilename = this.getUploadFilename(asRequest(request, file)); const uploadFilename = this.getUploadFilename(asUploadRequest(request, file));
const uploadFolder = this.getUploadFolder(asRequest(request, file)); const uploadFolder = this.getUploadFolder(asUploadRequest(request, file));
const uploadPath = `${uploadFolder}/${uploadFilename}`; const uploadPath = `${uploadFolder}/${uploadFilename}`;
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [uploadPath] } }); await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [uploadPath] } });
+12
View File
@@ -1,5 +1,7 @@
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { VECTOR_EXTENSIONS } from 'src/constants'; import { VECTOR_EXTENSIONS } from 'src/constants';
import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { import {
AssetMetadataKey, AssetMetadataKey,
AssetOrder, AssetOrder,
@@ -408,6 +410,16 @@ export interface UploadFile {
size: number; size: number;
} }
export type UploadRequest = {
auth: AuthDto | null;
fieldName: UploadFieldName;
file: UploadFile;
body: {
filename?: string;
[key: string]: unknown;
};
};
export interface UploadFiles { export interface UploadFiles {
assetData: ImmichFile[]; assetData: ImmichFile[];
sidecarData: ImmichFile[]; sidecarData: ImmichFile[];
+3 -2
View File
@@ -10,7 +10,7 @@ import { AccessRepository } from 'src/repositories/access.repository';
import { AssetRepository } from 'src/repositories/asset.repository'; import { AssetRepository } from 'src/repositories/asset.repository';
import { EventRepository } from 'src/repositories/event.repository'; import { EventRepository } from 'src/repositories/event.repository';
import { PartnerRepository } from 'src/repositories/partner.repository'; import { PartnerRepository } from 'src/repositories/partner.repository';
import { IBulkAsset, ImmichFile, UploadFile } from 'src/types'; import { IBulkAsset, ImmichFile, UploadFile, UploadRequest } from 'src/types';
import { checkAccess } from 'src/utils/access'; import { checkAccess } from 'src/utils/access';
export const getAssetFile = (files: AssetFile[], type: AssetFileType | GeneratedImageType) => { export const getAssetFile = (files: AssetFile[], type: AssetFileType | GeneratedImageType) => {
@@ -190,9 +190,10 @@ export function mapToUploadFile(file: ImmichFile): UploadFile {
}; };
} }
export const asRequest = (request: AuthRequest, file: Express.Multer.File) => { export const asUploadRequest = (request: AuthRequest, file: Express.Multer.File): UploadRequest => {
return { return {
auth: request.user || null, auth: request.user || null,
body: request.body,
fieldName: file.fieldname as UploadFieldName, fieldName: file.fieldname as UploadFieldName,
file: mapToUploadFile(file as ImmichFile), file: mapToUploadFile(file as ImmichFile),
}; };