Compare commits

..

8 Commits

Author SHA1 Message Date
mertalev
3946e8c0b2 don't close client 2025-09-02 13:17:40 -04:00
mertalev
1dbac30993 update other usages 2025-09-02 13:10:27 -04:00
mertalev
831fb5a2f9 set defaults 2025-09-02 13:03:30 -04:00
mertalev
8d272f8abc init before app launch 2025-09-02 12:38:11 -04:00
mertalev
3a2b572e0b custom user agent 2025-09-02 12:38:11 -04:00
mertalev
942b27241a fix hot reload 2025-09-02 12:38:11 -04:00
mertalev
482526475b uppercase http method 2025-09-02 12:38:11 -04:00
mertalev
c4bd24277a platform clients 2025-09-02 12:38:11 -04:00
44 changed files with 876 additions and 817 deletions

View File

@@ -34,7 +34,3 @@ The `/api/something` endpoint is now `/api/something-else`
- [ ] I have followed naming conventions/patterns in the surrounding code
- [ ] All code in `src/services/` uses repositories implementations for database calls, filesystem operations, etc.
- [ ] All code in `src/repositories/` is pretty basic/simple and does not have any immich specific logic (that belongs in `src/services/`)
## Please describe to which degree, if any, an LLM was used in creating this pull request.
...

View File

@@ -138,7 +138,7 @@ jobs:
name: Unit Test CLI (Windows)
needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_cli == 'true' }}
runs-on: windows-2025
runs-on: windows-latest
permissions:
contents: read
defaults:

View File

@@ -61,8 +61,9 @@ private open class BackgroundWorkerPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface BackgroundWorkerFgHostApi {
fun enable()
fun disable()
fun enableSyncWorker()
fun enableUploadWorker(callbackHandle: Long)
fun disableUploadWorker()
companion object {
/** The codec used by BackgroundWorkerFgHostApi. */
@@ -74,11 +75,11 @@ interface BackgroundWorkerFgHostApi {
fun setUp(binaryMessenger: BinaryMessenger, api: BackgroundWorkerFgHostApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable$separatedMessageChannelSuffix", codec)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableSyncWorker$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
api.enable()
api.enableSyncWorker()
listOf(null)
} catch (exception: Throwable) {
BackgroundWorkerPigeonUtils.wrapError(exception)
@@ -90,11 +91,29 @@ interface BackgroundWorkerFgHostApi {
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$separatedMessageChannelSuffix", codec)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val callbackHandleArg = args[0] as Long
val wrapped: List<Any?> = try {
api.enableUploadWorker(callbackHandleArg)
listOf(null)
} catch (exception: Throwable) {
BackgroundWorkerPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disableUploadWorker$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
api.disable()
api.disableUploadWorker()
listOf(null)
} catch (exception: Throwable) {
BackgroundWorkerPigeonUtils.wrapError(exception)
@@ -111,7 +130,6 @@ interface BackgroundWorkerFgHostApi {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface BackgroundWorkerBgHostApi {
fun onInitialized()
fun close()
companion object {
/** The codec used by BackgroundWorkerBgHostApi. */
@@ -138,22 +156,6 @@ interface BackgroundWorkerBgHostApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.close$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
api.close()
listOf(null)
} catch (exception: Throwable) {
BackgroundWorkerPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
@@ -165,6 +167,23 @@ class BackgroundWorkerFlutterApi(private val binaryMessenger: BinaryMessenger, p
BackgroundWorkerPigeonCodec()
}
}
fun onLocalSync(maxSecondsArg: Long?, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onLocalSync$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(maxSecondsArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
} else {
callback(Result.success(Unit))
}
} else {
callback(Result.failure(BackgroundWorkerPigeonUtils.createConnectionError(channelName)))
}
}
}
fun onIosUpload(isRefreshArg: Boolean, maxSecondsArg: Long?, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""

View File

@@ -11,11 +11,17 @@ import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.SettableFuture
import io.flutter.FlutterInjector
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor
import io.flutter.embedding.engine.dart.DartExecutor.DartCallback
import io.flutter.embedding.engine.loader.FlutterLoader
import io.flutter.view.FlutterCallbackInformation
private const val TAG = "BackgroundWorker"
enum class BackgroundTaskType {
LOCAL_SYNC,
UPLOAD,
}
class BackgroundWorker(context: Context, params: WorkerParameters) :
ListenableWorker(context, params), BackgroundWorkerBgHostApi {
private val ctx: Context = context.applicationContext
@@ -52,6 +58,25 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
engine = FlutterEngine(ctx)
// Retrieve the callback handle stored by the main Flutter app
// This handle points to the Flutter function that should be executed in the background
val callbackHandle =
ctx.getSharedPreferences(BackgroundWorkerApiImpl.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getLong(BackgroundWorkerApiImpl.SHARED_PREF_CALLBACK_HANDLE, 0L)
if (callbackHandle == 0L) {
// Without a valid callback handle, we cannot start the Flutter background execution
complete(Result.failure())
return@ensureInitializationCompleteAsync
}
// Start the Flutter engine with the specified callback as the entry point
val callback = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle)
if (callback == null) {
complete(Result.failure())
return@ensureInitializationCompleteAsync
}
// Register custom plugins
MainActivity.registerPlugins(ctx, engine!!)
flutterApi =
@@ -61,12 +86,8 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
api = this
)
engine!!.dartExecutor.executeDartEntrypoint(
DartExecutor.DartEntrypoint(
loader.findAppBundlePath(),
"package:immich_mobile/domain/services/background_worker.service.dart",
"backgroundSyncNativeEntrypoint"
)
engine!!.dartExecutor.executeDartCallback(
DartCallback(ctx.assets, loader.findAppBundlePath(), callback)
)
}
@@ -79,10 +100,23 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
* This method acts as a bridge between the native Android background task system and Flutter.
*/
override fun onInitialized() {
flutterApi?.onAndroidUpload { handleHostResult(it) }
val taskTypeIndex = inputData.getInt(BackgroundWorkerApiImpl.WORKER_DATA_TASK_TYPE, 0)
val taskType = BackgroundTaskType.entries[taskTypeIndex]
when (taskType) {
BackgroundTaskType.LOCAL_SYNC -> flutterApi?.onLocalSync(null) { handleHostResult(it) }
BackgroundTaskType.UPLOAD -> flutterApi?.onAndroidUpload { handleHostResult(it) }
}
}
override fun close() {
/**
* Called when the system has to stop this worker because constraints are
* no longer met or the system needs resources for more important tasks
* This is also called when the worker has been explicitly cancelled or replaced
*/
override fun onStopped() {
Log.d(TAG, "About to stop BackupWorker")
if (isComplete) {
return
}
@@ -100,16 +134,6 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
}, 5000)
}
/**
* Called when the system has to stop this worker because constraints are
* no longer met or the system needs resources for more important tasks
* This is also called when the worker has been explicitly cancelled or replaced
*/
override fun onStopped() {
Log.d(TAG, "About to stop BackupWorker")
close()
}
private fun handleHostResult(result: kotlin.Result<Unit>) {
if (isComplete) {
return

View File

@@ -3,8 +3,10 @@ package app.alextran.immich.background
import android.content.Context
import android.provider.MediaStore
import android.util.Log
import androidx.core.content.edit
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
@@ -14,13 +16,19 @@ private const val TAG = "BackgroundUploadImpl"
class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
private val ctx: Context = context.applicationContext
override fun enable() {
override fun enableSyncWorker() {
enqueueMediaObserver(ctx)
Log.i(TAG, "Scheduled media observer")
}
override fun disable() {
WorkManager.getInstance(ctx).cancelUniqueWork(OBSERVER_WORKER_NAME)
override fun enableUploadWorker(callbackHandle: Long) {
updateUploadEnabled(ctx, true)
updateCallbackHandle(ctx, callbackHandle)
Log.i(TAG, "Scheduled background upload tasks")
}
override fun disableUploadWorker() {
updateUploadEnabled(ctx, false)
WorkManager.getInstance(ctx).cancelUniqueWork(BACKGROUND_WORKER_NAME)
Log.i(TAG, "Cancelled background upload tasks")
}
@@ -29,14 +37,32 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
private const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1"
private const val OBSERVER_WORKER_NAME = "immich/MediaObserverV1"
const val WORKER_DATA_TASK_TYPE = "taskType"
const val SHARED_PREF_NAME = "Immich::Background"
const val SHARED_PREF_BACKUP_ENABLED = "Background::backup::enabled"
const val SHARED_PREF_CALLBACK_HANDLE = "Background::backup::callbackHandle"
private fun updateUploadEnabled(context: Context, enabled: Boolean) {
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE).edit {
putBoolean(SHARED_PREF_BACKUP_ENABLED, enabled)
}
}
private fun updateCallbackHandle(context: Context, callbackHandle: Long) {
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE).edit {
putLong(SHARED_PREF_CALLBACK_HANDLE, callbackHandle)
}
}
fun enqueueMediaObserver(ctx: Context) {
val constraints = Constraints.Builder()
.addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
.setTriggerContentUpdateDelay(30, TimeUnit.SECONDS)
.setTriggerContentMaxDelay(3, TimeUnit.MINUTES)
.setTriggerContentUpdateDelay(5, TimeUnit.SECONDS)
.setTriggerContentMaxDelay(1, TimeUnit.MINUTES)
.build()
val work = OneTimeWorkRequest.Builder(MediaObserver::class.java)
@@ -48,13 +74,15 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
Log.i(TAG, "Enqueued media observer worker with name: $OBSERVER_WORKER_NAME")
}
fun enqueueBackgroundWorker(ctx: Context) {
fun enqueueBackgroundWorker(ctx: Context, taskType: BackgroundTaskType) {
val constraints = Constraints.Builder().setRequiresBatteryNotLow(true).build()
val data = Data.Builder()
data.putInt(WORKER_DATA_TASK_TYPE, taskType.ordinal)
val work = OneTimeWorkRequest.Builder(BackgroundWorker::class.java)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
.build()
.setInputData(data.build()).build()
WorkManager.getInstance(ctx)
.enqueueUniqueWork(BACKGROUND_WORKER_NAME, ExistingWorkPolicy.REPLACE, work)

View File

@@ -6,17 +6,29 @@ import androidx.work.Worker
import androidx.work.WorkerParameters
class MediaObserver(context: Context, params: WorkerParameters) : Worker(context, params) {
private val ctx: Context = context.applicationContext
private val ctx: Context = context.applicationContext
override fun doWork(): Result {
Log.i("MediaObserver", "Content change detected, starting background worker")
// Re-enqueue itself to listen for future changes
BackgroundWorkerApiImpl.enqueueMediaObserver(ctx)
override fun doWork(): Result {
Log.i("MediaObserver", "Content change detected, starting background worker")
// Enqueue backup worker only if there are new media changes
if (triggeredContentUris.isNotEmpty()) {
BackgroundWorkerApiImpl.enqueueBackgroundWorker(ctx)
// Enqueue backup worker only if there are new media changes
if (triggeredContentUris.isNotEmpty()) {
val type =
if (isBackupEnabled(ctx)) BackgroundTaskType.UPLOAD else BackgroundTaskType.LOCAL_SYNC
BackgroundWorkerApiImpl.enqueueBackgroundWorker(ctx, type)
}
// Re-enqueue itself to listen for future changes
BackgroundWorkerApiImpl.enqueueMediaObserver(ctx)
return Result.success()
}
private fun isBackupEnabled(context: Context): Boolean {
val prefs =
context.getSharedPreferences(
BackgroundWorkerApiImpl.SHARED_PREF_NAME,
Context.MODE_PRIVATE
)
return prefs.getBoolean(BackgroundWorkerApiImpl.SHARED_PREF_BACKUP_ENABLED, false)
}
return Result.success()
}
}

View File

@@ -6,6 +6,9 @@ PODS:
- FlutterMacOS
- connectivity_plus (0.0.1):
- Flutter
- cupertino_http (0.0.1):
- Flutter
- FlutterMacOS
- device_info_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.9):
@@ -77,6 +80,8 @@ PODS:
- Flutter
- network_info_plus (0.0.1):
- Flutter
- objective_c (0.0.1):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
@@ -136,6 +141,7 @@ DEPENDENCIES:
- background_downloader (from `.symlinks/plugins/background_downloader/ios`)
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- cupertino_http (from `.symlinks/plugins/cupertino_http/darwin`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- Flutter (from `Flutter`)
@@ -154,6 +160,7 @@ DEPENDENCIES:
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
- native_video_player (from `.symlinks/plugins/native_video_player/ios`)
- network_info_plus (from `.symlinks/plugins/network_info_plus/ios`)
- objective_c (from `.symlinks/plugins/objective_c/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
@@ -184,6 +191,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/bonsoir_darwin/darwin"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
cupertino_http:
:path: ".symlinks/plugins/cupertino_http/darwin"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
file_picker:
@@ -220,6 +229,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/native_video_player/ios"
network_info_plus:
:path: ".symlinks/plugins/network_info_plus/ios"
objective_c:
:path: ".symlinks/plugins/objective_c/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
@@ -249,6 +260,7 @@ SPEC CHECKSUMS:
background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad
bonsoir_darwin: 29c7ccf356646118844721f36e1de4b61f6cbd0e
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
@@ -270,6 +282,7 @@ SPEC CHECKSUMS:
maplibre_gl: 3c924e44725147b03dda33430ad216005b40555f
native_video_player: b65c58951ede2f93d103a25366bdebca95081265
network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc
objective_c: 89e720c30d716b036faf9c9684022048eee1eee2
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d

View File

@@ -24,7 +24,7 @@ import UIKit
BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!)
BackgroundServicePlugin.registerBackgroundProcessing()
BackgroundWorkerApiImpl.registerBackgroundWorkers()
BackgroundWorkerApiImpl.registerBackgroundProcessing()
BackgroundServicePlugin.setPluginRegistrantCallback { registry in
if !registry.hasPlugin("org.cocoapods.path-provider-foundation") {

View File

@@ -73,8 +73,9 @@ class BackgroundWorkerPigeonCodec: FlutterStandardMessageCodec, @unchecked Senda
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol BackgroundWorkerFgHostApi {
func enable() throws
func disable() throws
func enableSyncWorker() throws
func enableUploadWorker(callbackHandle: Int64) throws
func disableUploadWorker() throws
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -83,38 +84,52 @@ class BackgroundWorkerFgHostApiSetup {
/// Sets up an instance of `BackgroundWorkerFgHostApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: BackgroundWorkerFgHostApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
let enableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
let enableSyncWorkerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableSyncWorker\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
enableChannel.setMessageHandler { _, reply in
enableSyncWorkerChannel.setMessageHandler { _, reply in
do {
try api.enable()
try api.enableSyncWorker()
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
enableChannel.setMessageHandler(nil)
enableSyncWorkerChannel.setMessageHandler(nil)
}
let disableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
let enableUploadWorkerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
disableChannel.setMessageHandler { _, reply in
enableUploadWorkerChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let callbackHandleArg = args[0] as! Int64
do {
try api.disable()
try api.enableUploadWorker(callbackHandle: callbackHandleArg)
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
disableChannel.setMessageHandler(nil)
enableUploadWorkerChannel.setMessageHandler(nil)
}
let disableUploadWorkerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disableUploadWorker\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
disableUploadWorkerChannel.setMessageHandler { _, reply in
do {
try api.disableUploadWorker()
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
disableUploadWorkerChannel.setMessageHandler(nil)
}
}
}
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol BackgroundWorkerBgHostApi {
func onInitialized() throws
func close() throws
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -136,23 +151,11 @@ class BackgroundWorkerBgHostApiSetup {
} else {
onInitializedChannel.setMessageHandler(nil)
}
let closeChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.close\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
closeChannel.setMessageHandler { _, reply in
do {
try api.close()
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
closeChannel.setMessageHandler(nil)
}
}
}
/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift.
protocol BackgroundWorkerFlutterApiProtocol {
func onLocalSync(maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result<Void, PigeonError>) -> Void)
func onIosUpload(isRefresh isRefreshArg: Bool, maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result<Void, PigeonError>) -> Void)
func onAndroidUpload(completion: @escaping (Result<Void, PigeonError>) -> Void)
func cancel(completion: @escaping (Result<Void, PigeonError>) -> Void)
@@ -167,6 +170,24 @@ class BackgroundWorkerFlutterApi: BackgroundWorkerFlutterApiProtocol {
var codec: BackgroundWorkerPigeonCodec {
return BackgroundWorkerPigeonCodec.shared
}
func onLocalSync(maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result<Void, PigeonError>) -> Void) {
let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onLocalSync\(messageChannelSuffix)"
let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec)
channel.sendMessage([maxSecondsArg] as [Any?]) { response in
guard let listResponse = response as? [Any?] else {
completion(.failure(createConnectionError(withChannelName: channelName)))
return
}
if listResponse.count > 1 {
let code: String = listResponse[0] as! String
let message: String? = nilOrValue(listResponse[1])
let details: String? = nilOrValue(listResponse[2])
completion(.failure(PigeonError(code: code, message: message, details: details)))
} else {
completion(.success(()))
}
}
}
func onIosUpload(isRefresh isRefreshArg: Bool, maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result<Void, PigeonError>) -> Void) {
let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload\(messageChannelSuffix)"
let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec)

View File

@@ -1,7 +1,7 @@
import BackgroundTasks
import Flutter
enum BackgroundTaskType { case refresh, processing }
enum BackgroundTaskType { case localSync, refreshUpload, processingUpload }
/*
* DEBUG: Testing Background Tasks in Xcode
@@ -9,6 +9,10 @@ enum BackgroundTaskType { case refresh, processing }
* To test background task functionality during development:
* 1. Pause the application in Xcode debugger
* 2. In the debugger console, enter one of the following commands:
## For local sync (short-running sync):
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"app.alextran.immich.background.localSync"]
## For background refresh (short-running sync):
@@ -20,6 +24,8 @@ enum BackgroundTaskType { case refresh, processing }
* To simulate task expiration (useful for testing expiration handlers):
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"app.alextran.immich.background.localSync"]
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"app.alextran.immich.background.refreshUpload"]
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"app.alextran.immich.background.processingUpload"]
@@ -80,10 +86,28 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
* starts the engine, and sets up a timeout timer if specified.
*/
func run() {
// Retrieve the callback handle stored by the main Flutter app
// This handle points to the Flutter function that should be executed in the background
let callbackHandle = Int64(UserDefaults.standard.string(
forKey: BackgroundWorkerApiImpl.backgroundUploadCallbackHandleKey) ?? "0") ?? 0
if callbackHandle == 0 {
// Without a valid callback handle, we cannot start the Flutter background execution
complete(success: false)
return
}
// Use the callback handle to retrieve the actual Flutter callback information
guard let callback = FlutterCallbackCache.lookupCallbackInformation(callbackHandle) else {
// The callback handle is invalid or the callback was not found
complete(success: false)
return
}
// Start the Flutter engine with the specified callback as the entry point
let isRunning = engine.run(
withEntrypoint: "backgroundSyncNativeEntrypoint",
libraryURI: "package:immich_mobile/domain/services/background_worker.service.dart"
withEntrypoint: callback.callbackName,
libraryURI: callback.callbackLibraryPath
)
// Verify that the Flutter engine started successfully
@@ -103,7 +127,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
if maxSeconds != nil {
// Schedule a timer to cancel the task after the specified timeout period
Timer.scheduledTimer(withTimeInterval: TimeInterval(maxSeconds!), repeats: false) { _ in
self.close()
self.cancel()
}
}
}
@@ -114,9 +138,17 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
* This method acts as a bridge between the native iOS background task system and Flutter.
*/
func onInitialized() throws {
flutterApi?.onIosUpload(isRefresh: self.taskType == .refresh, maxSeconds: maxSeconds.map { Int64($0) }, completion: { result in
self.handleHostResult(result: result)
})
switch self.taskType {
case .refreshUpload, .processingUpload:
flutterApi?.onIosUpload(isRefresh: self.taskType == .refreshUpload,
maxSeconds: maxSeconds.map { Int64($0) }, completion: { result in
self.handleHostResult(result: result)
})
case .localSync:
flutterApi?.onLocalSync(maxSeconds: maxSeconds.map { Int64($0) }, completion: { result in
self.handleHostResult(result: result)
})
}
}
/**
@@ -124,7 +156,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
* Sends a cancel signal to the Flutter side and sets up a fallback timer to ensure
* the completion handler is eventually called even if Flutter doesn't respond.
*/
func close() {
func cancel() {
if isComplete {
return
}
@@ -150,7 +182,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
private func handleHostResult(result: Result<Void, PigeonError>) {
switch result {
case .success(): self.complete(success: true)
case .failure(_): self.close()
case .failure(_): self.cancel()
}
}
@@ -163,10 +195,6 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
* - Parameter success: Indicates whether the background task completed successfully
*/
private func complete(success: Bool) {
if(isComplete) {
return
}
isComplete = true
engine.destroyContext()
completionHandler(success)

View File

@@ -1,40 +1,84 @@
import BackgroundTasks
class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
func enable() throws {
BackgroundWorkerApiImpl.scheduleRefreshWorker()
BackgroundWorkerApiImpl.scheduleProcessingWorker()
print("BackgroundUploadImpl:enbale Background worker scheduled")
func enableSyncWorker() throws {
BackgroundWorkerApiImpl.scheduleLocalSync()
print("BackgroundUploadImpl:enableSyncWorker Local Sync worker scheduled")
}
func disable() throws {
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.refreshTaskID);
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.processingTaskID);
print("BackgroundUploadImpl:disableUploadWorker Disabled background workers")
func enableUploadWorker(callbackHandle: Int64) throws {
BackgroundWorkerApiImpl.updateUploadEnabled(true)
// Store the callback handle for later use when starting background Flutter isolates
BackgroundWorkerApiImpl.updateUploadCallbackHandle(callbackHandle)
BackgroundWorkerApiImpl.scheduleRefreshUpload()
BackgroundWorkerApiImpl.scheduleProcessingUpload()
print("BackgroundUploadImpl:enableUploadWorker Scheduled background upload tasks")
}
private static let refreshTaskID = "app.alextran.immich.background.refreshUpload"
private static let processingTaskID = "app.alextran.immich.background.processingUpload"
func disableUploadWorker() throws {
BackgroundWorkerApiImpl.updateUploadEnabled(false)
BackgroundWorkerApiImpl.cancelUploadTasks()
print("BackgroundUploadImpl:disableUploadWorker Disabled background upload tasks")
}
public static let backgroundUploadEnabledKey = "immich:background:backup:enabled"
public static let backgroundUploadCallbackHandleKey = "immich:background:backup:callbackHandle"
private static let localSyncTaskID = "app.alextran.immich.background.localSync"
private static let refreshUploadTaskID = "app.alextran.immich.background.refreshUpload"
private static let processingUploadTaskID = "app.alextran.immich.background.processingUpload"
public static func registerBackgroundWorkers() {
private static func updateUploadEnabled(_ isEnabled: Bool) {
return UserDefaults.standard.set(isEnabled, forKey: BackgroundWorkerApiImpl.backgroundUploadEnabledKey)
}
private static func updateUploadCallbackHandle(_ callbackHandle: Int64) {
return UserDefaults.standard.set(String(callbackHandle), forKey: BackgroundWorkerApiImpl.backgroundUploadCallbackHandleKey)
}
private static func cancelUploadTasks() {
BackgroundWorkerApiImpl.updateUploadEnabled(false)
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: refreshUploadTaskID);
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: processingUploadTaskID);
}
public static func registerBackgroundProcessing() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: processingTaskID, using: nil) { task in
forTaskWithIdentifier: processingUploadTaskID, using: nil) { task in
if task is BGProcessingTask {
handleBackgroundProcessing(task: task as! BGProcessingTask)
}
}
BGTaskScheduler.shared.register(
forTaskWithIdentifier: refreshTaskID, using: nil) { task in
forTaskWithIdentifier: refreshUploadTaskID, using: nil) { task in
if task is BGAppRefreshTask {
handleBackgroundRefresh(task: task as! BGAppRefreshTask)
handleBackgroundRefresh(task: task as! BGAppRefreshTask, taskType: .refreshUpload)
}
}
BGTaskScheduler.shared.register(
forTaskWithIdentifier: localSyncTaskID, using: nil) { task in
if task is BGAppRefreshTask {
handleBackgroundRefresh(task: task as! BGAppRefreshTask, taskType: .localSync)
}
}
}
private static func scheduleLocalSync() {
let backgroundRefresh = BGAppRefreshTaskRequest(identifier: localSyncTaskID)
backgroundRefresh.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60) // 5 mins
do {
try BGTaskScheduler.shared.submit(backgroundRefresh)
} catch {
print("Could not schedule the local sync task \(error.localizedDescription)")
}
}
private static func scheduleRefreshWorker() {
let backgroundRefresh = BGAppRefreshTaskRequest(identifier: refreshTaskID)
private static func scheduleRefreshUpload() {
let backgroundRefresh = BGAppRefreshTaskRequest(identifier: refreshUploadTaskID)
backgroundRefresh.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60) // 5 mins
do {
@@ -44,8 +88,8 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
}
}
private static func scheduleProcessingWorker() {
let backgroundProcessing = BGProcessingTaskRequest(identifier: processingTaskID)
private static func scheduleProcessingUpload() {
let backgroundProcessing = BGProcessingTaskRequest(identifier: processingUploadTaskID)
backgroundProcessing.requiresNetworkConnectivity = true
backgroundProcessing.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 mins
@@ -57,16 +101,16 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
}
}
private static func handleBackgroundRefresh(task: BGAppRefreshTask) {
scheduleRefreshWorker()
// Restrict the refresh task to run only for a maximum of (maxSeconds) seconds
runBackgroundWorker(task: task, taskType: .refresh, maxSeconds: 20)
private static func handleBackgroundRefresh(task: BGAppRefreshTask, taskType: BackgroundTaskType) {
scheduleRefreshUpload()
// Restrict the refresh task to run only for a maximum of 20 seconds
runBackgroundWorker(task: task, taskType: taskType, maxSeconds: 20)
}
private static func handleBackgroundProcessing(task: BGProcessingTask) {
scheduleProcessingWorker()
scheduleProcessingUpload()
// There are no restrictions for processing tasks. Although, the OS could signal expiration at any time
runBackgroundWorker(task: task, taskType: .processing, maxSeconds: nil)
runBackgroundWorker(task: task, taskType: .processingUpload, maxSeconds: nil)
}
/**
@@ -90,7 +134,7 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
task.expirationHandler = {
DispatchQueue.main.async {
backgroundWorker.close()
backgroundWorker.cancel()
}
isSuccess = false

View File

@@ -1,189 +1,190 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>app.alextran.immich.background.refreshUpload</string>
<string>app.alextran.immich.background.processingUpload</string>
<string>app.alextran.immich.backgroundFetch</string>
<string>app.alextran.immich.backgroundProcessing</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>${PRODUCT_NAME}</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>ShareHandler</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.file-url</string>
<string>public.image</string>
<string>public.text</string>
<string>public.movie</string>
<string>public.url</string>
<string>public.data</string>
</array>
</dict>
</array>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>ar</string>
<string>ca</string>
<string>cs</string>
<string>da</string>
<string>de</string>
<string>es</string>
<string>fi</string>
<string>fr</string>
<string>he</string>
<string>hi</string>
<string>hu</string>
<string>it</string>
<string>ja</string>
<string>ko</string>
<string>lv</string>
<string>mn</string>
<string>nb</string>
<string>nl</string>
<string>pl</string>
<string>pt</string>
<string>ro</string>
<string>ru</string>
<string>sk</string>
<string>sl</string>
<string>sr</string>
<string>sv</string>
<string>th</string>
<string>uk</string>
<string>vi</string>
<string>zh</string>
</array>
<key>CFBundleName</key>
<string>immich_mobile</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.140.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>Share Extension</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>Deep Link</string>
<key>CFBundleURLSchemes</key>
<array>
<string>immich</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>219</string>
<key>FLTEnableImpeller</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<string>No</string>
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSBonjourServices</key>
<array>
<string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string>
</array>
<key>NSCameraUsageDescription</key>
<string>We need to access the camera to let you take beautiful video using this app</string>
<key>NSFaceIDUsageDescription</key>
<string>We need to use FaceID to allow access to your locked folder</string>
<key>NSLocalNetworkUsageDescription</key>
<string>We need local network permission to connect to the local server using IP address and
<dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>app.alextran.immich.background.localSync</string>
<string>app.alextran.immich.background.refreshUpload</string>
<string>app.alextran.immich.background.processingUpload</string>
<string>app.alextran.immich.backgroundFetch</string>
<string>app.alextran.immich.backgroundProcessing</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true />
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>${PRODUCT_NAME}</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>ShareHandler</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.file-url</string>
<string>public.image</string>
<string>public.text</string>
<string>public.movie</string>
<string>public.url</string>
<string>public.data</string>
</array>
</dict>
</array>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>ar</string>
<string>ca</string>
<string>cs</string>
<string>da</string>
<string>de</string>
<string>es</string>
<string>fi</string>
<string>fr</string>
<string>he</string>
<string>hi</string>
<string>hu</string>
<string>it</string>
<string>ja</string>
<string>ko</string>
<string>lv</string>
<string>mn</string>
<string>nb</string>
<string>nl</string>
<string>pl</string>
<string>pt</string>
<string>ro</string>
<string>ru</string>
<string>sk</string>
<string>sl</string>
<string>sr</string>
<string>sv</string>
<string>th</string>
<string>uk</string>
<string>vi</string>
<string>zh</string>
</array>
<key>CFBundleName</key>
<string>immich_mobile</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.140.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>Share Extension</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>Deep Link</string>
<key>CFBundleURLSchemes</key>
<array>
<string>immich</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>219</string>
<key>FLTEnableImpeller</key>
<true />
<key>ITSAppUsesNonExemptEncryption</key>
<false />
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true />
<key>LSSupportsOpeningDocumentsInPlace</key>
<string>No</string>
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
<true />
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true />
</dict>
<key>NSBonjourServices</key>
<array>
<string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string>
</array>
<key>NSCameraUsageDescription</key>
<string>We need to access the camera to let you take beautiful video using this app</string>
<key>NSFaceIDUsageDescription</key>
<string>We need to use FaceID to allow access to your locked folder</string>
<key>NSLocalNetworkUsageDescription</key>
<string>We need local network permission to connect to the local server using IP address and
allow the casting feature to work</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
<key>NSLocationUsageDescription</key>
<string>We require this permission to access the local WiFi name</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name</string>
<key>NSMicrophoneUsageDescription</key>
<string>We need to access the microphone to let you take beautiful video using this app</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>io.flutter.embedded_views_preview</key>
<true/>
</dict>
</plist>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
<key>NSLocationUsageDescription</key>
<string>We require this permission to access the local WiFi name</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name</string>
<key>NSMicrophoneUsageDescription</key>
<string>We need to access the microphone to let you take beautiful video using this app</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true />
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false />
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true />
<key>io.flutter.embedded_views_preview</key>
<true />
</dict>
</plist>

View File

@@ -14,7 +14,6 @@ import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/auth.service.dart';
import 'package:immich_mobile/services/localization.service.dart';
@@ -30,9 +29,13 @@ class BackgroundWorkerFgService {
const BackgroundWorkerFgService(this._foregroundHostApi);
// TODO: Move this call to native side once old timeline is removed
Future<void> enable() => _foregroundHostApi.enable();
Future<void> enableSyncService() => _foregroundHostApi.enableSyncWorker();
Future<void> disable() => _foregroundHostApi.disable();
Future<void> enableUploadService() => _foregroundHostApi.enableUploadWorker(
PluginUtilities.getCallbackHandle(_backgroundSyncNativeEntrypoint)!.toRawHandle(),
);
Future<void> disableUploadService() => _foregroundHostApi.disableUploadWorker();
}
class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
@@ -41,7 +44,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
final Drift _drift;
final DriftLogger _driftLogger;
final BackgroundWorkerBgHostApi _backgroundHostApi;
final Logger _logger = Logger('BackgroundWorkerBgService');
final Logger _logger = Logger('BackgroundUploadBgService');
bool _isCleanedUp = false;
@@ -63,85 +66,92 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
bool get _isBackupEnabled => _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
Future<void> init() async {
try {
await loadTranslations();
HttpSSLOptions.apply(applyNative: false);
await _ref.read(authServiceProvider).setOpenApiServiceEndpoint();
await loadTranslations();
HttpSSLOptions.apply(applyNative: false);
await _ref.read(authServiceProvider).setOpenApiServiceEndpoint();
// Initialize the file downloader
await FileDownloader().configure(
globalConfig: [
// maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3
(Config.holdingQueue, (6, 6, 3)),
// On Android, if files are larger than 256MB, run in foreground service
(Config.runInForegroundIfFileLargerThan, 256),
],
);
await FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false);
await FileDownloader().trackTasks();
configureFileDownloaderNotifications();
// Initialize the file downloader
await FileDownloader().configure(
globalConfig: [
// maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3
(Config.holdingQueue, (6, 6, 3)),
// On Android, if files are larger than 256MB, run in foreground service
(Config.runInForegroundIfFileLargerThan, 256),
],
);
await FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false);
await FileDownloader().trackTasks();
configureFileDownloaderNotifications();
await _ref.read(fileMediaRepositoryProvider).enableBackgroundAccess();
// Notify the host that the background worker service has been initialized and is ready to use
_backgroundHostApi.onInitialized();
} catch (error, stack) {
_logger.severe("Failed to initialize background worker", error, stack);
_backgroundHostApi.close();
}
// Notify the host that the background upload service has been initialized and is ready to use
await _backgroundHostApi.onInitialized();
}
@override
Future<void> onLocalSync(int? maxSeconds) async {
_logger.info('Local background syncing started');
final sw = Stopwatch()..start();
final timeout = maxSeconds != null ? Duration(seconds: maxSeconds) : null;
await _syncAssets(hashTimeout: timeout, syncRemote: false);
sw.stop();
_logger.info("Local sync completed in ${sw.elapsed.inSeconds}s");
}
/* We do the following on Android upload
* - Sync local assets
* - Hash local assets 3 / 6 minutes
* - Sync remote assets
* - Check and requeue upload tasks
*/
@override
Future<void> onAndroidUpload() async {
try {
_logger.info('Android background processing started');
final sw = Stopwatch()..start();
_logger.info('Android background processing started');
final sw = Stopwatch()..start();
await _syncAssets(hashTimeout: Duration(minutes: _isBackupEnabled ? 3 : 6));
await _handleBackup(processBulk: false);
await _syncAssets(hashTimeout: Duration(minutes: _isBackupEnabled ? 3 : 6));
await _handleBackup(processBulk: false);
sw.stop();
_logger.info("Android background processing completed in ${sw.elapsed.inSeconds}s");
} catch (error, stack) {
_logger.severe("Failed to complete Android background processing", error, stack);
} finally {
await _cleanup();
}
await _cleanup();
sw.stop();
_logger.info("Android background processing completed in ${sw.elapsed.inSeconds}s");
}
/* We do the following on background upload
* - Sync local assets
* - Hash local assets
* - Sync remote assets
* - Check and requeue upload tasks
*
* The native side will not send the maxSeconds value for processing tasks
*/
@override
Future<void> onIosUpload(bool isRefresh, int? maxSeconds) async {
try {
_logger.info('iOS background upload started with maxSeconds: ${maxSeconds}s');
final sw = Stopwatch()..start();
_logger.info('iOS background upload started with maxSeconds: ${maxSeconds}s');
final sw = Stopwatch()..start();
final timeout = isRefresh ? const Duration(seconds: 5) : Duration(minutes: _isBackupEnabled ? 3 : 6);
await _syncAssets(hashTimeout: timeout);
final timeout = isRefresh ? const Duration(seconds: 5) : Duration(minutes: _isBackupEnabled ? 3 : 6);
await _syncAssets(hashTimeout: timeout);
final backupFuture = _handleBackup();
if (maxSeconds != null) {
await backupFuture.timeout(Duration(seconds: maxSeconds - 1), onTimeout: () {});
} else {
await backupFuture;
}
sw.stop();
_logger.info("iOS background upload completed in ${sw.elapsed.inSeconds}s");
} catch (error, stack) {
_logger.severe("Failed to complete iOS background upload", error, stack);
} finally {
await _cleanup();
final backupFuture = _handleBackup();
if (maxSeconds != null) {
await backupFuture.timeout(Duration(seconds: maxSeconds - 1), onTimeout: () {});
} else {
await backupFuture;
}
await _cleanup();
sw.stop();
_logger.info("iOS background upload completed in ${sw.elapsed.inSeconds}s");
}
@override
Future<void> cancel() async {
_logger.warning("Background worker cancelled");
try {
await _cleanup();
} catch (error, stack) {
debugPrint('Failed to cleanup background worker: $error with stack: $stack');
}
_logger.warning("Background upload cancelled");
await _cleanup();
}
Future<void> _cleanup() async {
@@ -149,21 +159,13 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
return;
}
try {
_isCleanedUp = true;
_logger.info("Cleaning up background worker");
await _ref.read(backgroundSyncProvider).cancel();
await _ref.read(backgroundSyncProvider).cancelLocal();
if (_isar.isOpen) {
await _isar.close();
}
await _drift.close();
await _driftLogger.close();
_ref.dispose();
debugPrint("Background worker cleaned up");
} catch (error, stack) {
debugPrint('Failed to cleanup background worker: $error with stack: $stack');
}
_isCleanedUp = true;
await _ref.read(backgroundSyncProvider).cancel();
await _ref.read(backgroundSyncProvider).cancelLocal();
await _isar.close();
await _drift.close();
await _driftLogger.close();
_ref.dispose();
}
Future<void> _handleBackup({bool processBulk = true}) async {
@@ -188,7 +190,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
}
}
Future<void> _syncAssets({Duration? hashTimeout}) async {
Future<void> _syncAssets({Duration? hashTimeout, bool syncRemote = true}) async {
final futures = <Future<void>>[];
final localSyncFuture = _ref.read(backgroundSyncProvider).syncLocal().then((_) async {
@@ -210,16 +212,17 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
});
futures.add(localSyncFuture);
futures.add(_ref.read(backgroundSyncProvider).syncRemote());
if (syncRemote) {
final remoteSyncFuture = _ref.read(backgroundSyncProvider).syncRemote();
futures.add(remoteSyncFuture);
}
await Future.wait(futures);
}
}
/// Native entry invoked from the background worker. If renaming or moving this to a different
/// library, make sure to update the entry points and URI in native workers as well
@pragma('vm:entry-point')
Future<void> backgroundSyncNativeEntrypoint() async {
Future<void> _backgroundSyncNativeEntrypoint() async {
WidgetsFlutterBinding.ensureInitialized();
DartPluginRegistrant.ensureInitialized();

View File

@@ -1,7 +1,6 @@
import 'dart:convert';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
@@ -36,7 +35,6 @@ class HashService {
bool get isCancelled => _cancelChecker?.call() ?? false;
Future<void> hashAssets() async {
_log.info("Starting hashing of assets");
final Stopwatch stopwatch = Stopwatch()..start();
// Sorted by backupSelection followed by isCloud
final localAlbums = await _localAlbumRepository.getAll(
@@ -51,7 +49,7 @@ class HashService {
final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
if (assetsToHash.isNotEmpty) {
await _hashAssets(album, assetsToHash);
await _hashAssets(assetsToHash);
}
}
@@ -62,7 +60,7 @@ class HashService {
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
/// with hash for those that were successfully hashed. Hashes are looked up in a table
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash) async {
Future<void> _hashAssets(List<LocalAsset> assetsToHash) async {
int bytesProcessed = 0;
final toHash = <_AssetToPath>[];
@@ -74,9 +72,6 @@ class HashService {
final file = await _storageRepository.getFileForAsset(asset.id);
if (file == null) {
_log.warning(
"Cannot get file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt} from album: ${album.name}",
);
continue;
}
@@ -84,17 +79,17 @@ class HashService {
toHash.add(_AssetToPath(asset: asset, path: file.path));
if (toHash.length >= batchFileLimit || bytesProcessed >= batchSizeLimit) {
await _processBatch(album, toHash);
await _processBatch(toHash);
toHash.clear();
bytesProcessed = 0;
}
}
await _processBatch(album, toHash);
await _processBatch(toHash);
}
/// Processes a batch of assets.
Future<void> _processBatch(LocalAlbum album, List<_AssetToPath> toHash) async {
Future<void> _processBatch(List<_AssetToPath> toHash) async {
if (toHash.isEmpty) {
return;
}
@@ -119,9 +114,7 @@ class HashService {
if (hash?.length == 20) {
hashed.add(asset.copyWith(checksum: base64.encode(hash!)));
} else {
_log.warning(
"Failed to hash file for ${asset.id}: ${asset.name} created at ${asset.createdAt} from album: ${album.name}",
);
_log.warning("Failed to hash file for ${asset.id}: ${asset.name} created at ${asset.createdAt}");
}
}

View File

@@ -6,7 +6,6 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:logging/logging.dart';
import 'package:platform/platform.dart';
@@ -286,7 +285,7 @@ extension on Iterable<PlatformAlbum> {
(e) => LocalAlbum(
id: e.id,
name: e.name,
updatedAt: tryFromSecondsSinceEpoch(e.updatedAt) ?? DateTime.now(),
updatedAt: e.updatedAt == null ? DateTime.now() : DateTime.fromMillisecondsSinceEpoch(e.updatedAt! * 1000),
assetCount: e.assetCount,
),
).toList();
@@ -301,8 +300,8 @@ extension on Iterable<PlatformAsset> {
name: e.name,
checksum: null,
type: AssetType.values.elementAtOrNull(e.type) ?? AssetType.other,
createdAt: tryFromSecondsSinceEpoch(e.createdAt) ?? DateTime.now(),
updatedAt: tryFromSecondsSinceEpoch(e.updatedAt) ?? DateTime.now(),
createdAt: e.createdAt == null ? DateTime.now() : DateTime.fromMillisecondsSinceEpoch(e.createdAt! * 1000),
updatedAt: e.updatedAt == null ? DateTime.now() : DateTime.fromMillisecondsSinceEpoch(e.updatedAt! * 1000),
width: e.width,
height: e.height,
durationInSeconds: e.durationInSeconds,

View File

@@ -1,15 +1,16 @@
import 'dart:async';
import 'dart:ffi';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:cronet_http/cronet_http.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:ffi/ffi.dart';
import 'package:http/http.dart' as http;
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:logging/logging.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
part 'local_image_request.dart';
part 'thumbhash_image_request.dart';

View File

@@ -1,14 +1,18 @@
part of 'image_request.dart';
class RemoteImageRequest extends ImageRequest {
static final log = Logger('RemoteImageRequest');
static final client = HttpClient()..maxConnectionsPerHost = 16;
final RemoteCacheManager? cacheManager;
static final _client = const NetworkRepository().getHttpClient(
'thumbnails',
diskCapacity: kThumbnailDiskCacheSize,
memoryCapacity: 0,
maxConnections: 16,
cacheMode: CacheMode.disk,
);
final String uri;
final Map<String, String> headers;
HttpClientRequest? _request;
final abortTrigger = Completer<void>();
RemoteImageRequest({required this.uri, required this.headers, this.cacheManager});
RemoteImageRequest({required this.uri, required this.headers});
@override
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
@@ -16,15 +20,8 @@ class RemoteImageRequest extends ImageRequest {
return null;
}
// TODO: the cache manager makes everything sequential with its DB calls and its operations cannot be cancelled,
// so it ends up being a bottleneck. We only prefer fetching from it when it can skip the DB call.
final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: true);
if (cachedFileImage != null) {
return cachedFileImage;
}
try {
final buffer = await _downloadImage(uri);
final buffer = await _downloadImage();
if (buffer == null) {
return null;
}
@@ -35,57 +32,41 @@ class RemoteImageRequest extends ImageRequest {
return null;
}
final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: false);
if (cachedFileImage != null) {
return cachedFileImage;
}
rethrow;
} finally {
_request = null;
}
}
Future<ImmutableBuffer?> _downloadImage(String url) async {
Future<ImmutableBuffer?> _downloadImage() async {
if (_isCancelled) {
return null;
}
final request = _request = await client.getUrl(Uri.parse(url));
if (_isCancelled) {
request.abort();
return _request = null;
}
for (final entry in headers.entries) {
request.headers.set(entry.key, entry.value);
}
final response = await request.close();
final req = http.AbortableRequest('GET', Uri.parse(uri), abortTrigger: abortTrigger.future);
req.headers.addAll(headers);
final res = await _client.send(req);
if (_isCancelled) {
_onCancelled();
return null;
}
final cacheManager = this.cacheManager;
final streamController = StreamController<List<int>>(sync: true);
final Stream<List<int>> stream;
cacheManager?.putStreamedFile(url, streamController.stream);
stream = response.map((chunk) {
if (res.statusCode != 200) {
throw Exception('Failed to download $uri: ${res.statusCode}');
}
final stream = res.stream.map((chunk) {
if (_isCancelled) {
throw StateError('Cancelled request');
}
if (cacheManager != null) {
streamController.add(chunk);
}
return chunk;
});
try {
final Uint8List bytes = await _downloadBytes(stream, response.contentLength);
streamController.close();
final Uint8List bytes = await _downloadBytes(stream, res.contentLength ?? -1);
if (_isCancelled) {
return null;
}
return await ImmutableBuffer.fromUint8List(bytes);
} catch (e) {
streamController.addError(e);
streamController.close();
if (_isCancelled) {
return null;
}
@@ -122,40 +103,6 @@ class RemoteImageRequest extends ImageRequest {
return bytes;
}
Future<ImageInfo?> _loadCachedFile(
String url,
ImageDecoderCallback decode,
double scale, {
required bool inMemoryOnly,
}) async {
final cacheManager = this.cacheManager;
if (_isCancelled || cacheManager == null) {
return null;
}
final file = await (inMemoryOnly ? cacheManager.getFileFromMemory(url) : cacheManager.getFileFromCache(url));
if (_isCancelled || file == null) {
return null;
}
try {
final buffer = await ImmutableBuffer.fromFilePath(file.file.path);
return await _decodeBuffer(buffer, decode, scale);
} catch (e) {
log.severe('Failed to decode cached image', e);
_evictFile(url);
return null;
}
}
Future<void> _evictFile(String url) async {
try {
await cacheManager?.removeFile(url);
} catch (e) {
log.severe('Failed to remove cached image', e);
}
}
Future<ImageInfo?> _decodeBuffer(ImmutableBuffer buffer, ImageDecoderCallback decode, scale) async {
if (_isCancelled) {
buffer.dispose();
@@ -173,7 +120,6 @@ class RemoteImageRequest extends ImageRequest {
@override
void _onCancelled() {
_request?.abort();
_request = null;
abortTrigger.complete();
}
}

View File

@@ -0,0 +1,65 @@
import 'dart:io';
import 'package:cronet_http/cronet_http.dart';
import 'package:cupertino_http/cupertino_http.dart';
import 'package:http/http.dart' as http;
import 'package:immich_mobile/utils/user_agent.dart';
import 'package:path_provider/path_provider.dart';
class NetworkRepository {
static late Directory _cachePath;
static late String _userAgent;
static final _clients = <String, http.Client>{};
static Future<void> init() {
return (
getTemporaryDirectory().then((cachePath) => _cachePath = cachePath),
getUserAgentString().then((userAgent) => _userAgent = userAgent),
).wait;
}
static void reset() {
Future.microtask(init);
for (final client in _clients.values) {
client.close();
}
_clients.clear();
}
const NetworkRepository();
http.Client getHttpClient(
String directoryName, {
int diskCapacity = 100 << 20,
int memoryCapacity = 10 << 20,
int maxConnections = 6,
CacheMode cacheMode = CacheMode.disk,
}) {
final cachedClient = _clients[directoryName];
if (cachedClient != null) {
return cachedClient;
}
final directory = Directory('${_cachePath.path}/$directoryName');
directory.createSync(recursive: true);
if (Platform.isAndroid) {
final engine = CronetEngine.build(
cacheMode: cacheMode,
cacheMaxSize: diskCapacity,
storagePath: directory.path,
userAgent: _userAgent,
);
return _clients[directoryName] = CronetClient.fromCronetEngine(engine, closeEngine: true);
}
final config = URLSessionConfiguration.defaultSessionConfiguration()
..httpMaximumConnectionsPerHost = maxConnections
..cache = URLCache.withCapacity(
diskCapacity: diskCapacity,
memoryCapacity: memoryCapacity,
directory: directory.uri,
)
..httpAdditionalHeaders = {'User-Agent': _userAgent};
return _clients[directoryName] = CupertinoClient.fromSessionConfiguration(config);
}
}

View File

@@ -16,13 +16,6 @@ class StorageRepository {
file = await entity?.originFile;
if (file == null) {
log.warning("Cannot get file for asset $assetId");
return null;
}
final exists = await file.exists();
if (!exists) {
log.warning("File for asset $assetId does not exist");
return null;
}
} catch (error, stackTrace) {
log.warning("Error getting file for asset $assetId", error, stackTrace);
@@ -41,13 +34,6 @@ class StorageRepository {
log.warning(
"Cannot get motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
);
return null;
}
final exists = await file.exists();
if (!exists) {
log.warning("Motion file for asset ${asset.id} does not exist");
return null;
}
} catch (error, stackTrace) {
log.warning(

View File

@@ -4,11 +4,13 @@ import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
class SyncApiRepository {
static final _client = const NetworkRepository().getHttpClient('api');
final Logger _logger = Logger('SyncApiRepository');
final ApiService _api;
SyncApiRepository(this._api);
@@ -20,10 +22,8 @@ class SyncApiRepository {
Future<void> streamChanges(
Function(List<SyncEvent>, Function() abort) onData, {
int batchSize = kSyncEventBatchSize,
http.Client? httpClient,
}) async {
final stopwatch = Stopwatch()..start();
final client = httpClient ?? http.Client();
final endpoint = "${_api.apiClient.basePath}/sync/stream";
final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'};
@@ -70,7 +70,7 @@ class SyncApiRepository {
}
try {
final response = await client.send(request);
final response = await _client.send(request);
if (response.statusCode != 200) {
final errorBody = await response.stream.bytesToString();
@@ -101,8 +101,6 @@ class SyncApiRepository {
} catch (error, stack) {
_logger.severe("Error processing stream", error, stack);
return Future.error(error, stack);
} finally {
client.close();
}
stopwatch.stop();
_logger.info("Remote Sync completed in ${stopwatch.elapsed.inMilliseconds}ms");

View File

@@ -15,7 +15,9 @@ import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart';
@@ -25,6 +27,7 @@ import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/theme.provider.dart';
import 'package:immich_mobile/routing/app_navigation_observer.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/deep_link.service.dart';
import 'package:immich_mobile/services/local_notification.service.dart';
@@ -112,6 +115,8 @@ Future<void> initApp() async {
yield LicenseEntryWithLineBreaks([license.key], license.value);
}
});
await NetworkRepository.init();
}
class ImmichApp extends ConsumerStatefulWidget {
@@ -204,11 +209,14 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
WidgetsBinding.instance.addPostFrameCallback((_) {
// needs to be delayed so that EasyLocalization is working
if (Store.isBetaTimelineEnabled) {
ref.read(backgroundServiceProvider).disableService();
ref.read(driftBackgroundUploadFgService).enable();
ref.read(driftBackgroundUploadFgService).enableSyncService();
if (ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup)) {
ref.read(backgroundServiceProvider).disableService();
ref.read(driftBackgroundUploadFgService).enableUploadService();
}
} else {
ref.read(driftBackgroundUploadFgService).disable();
ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
ref.read(driftBackgroundUploadFgService).disableUploadService();
}
});
@@ -221,6 +229,14 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
super.dispose();
}
@override
void reassemble() {
if (kDebugMode) {
NetworkRepository.reset();
}
super.reassemble();
}
@override
Widget build(BuildContext context) {
final router = ref.watch(appRouterProvider);

View File

@@ -8,6 +8,7 @@ import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@@ -42,10 +43,12 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
await ref.read(backgroundSyncProvider).syncRemote();
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
await ref.read(driftBackgroundUploadFgService).enableUploadService();
await ref.read(driftBackupProvider.notifier).startBackup(currentUser.id);
}
Future<void> stopBackup() async {
await ref.read(driftBackgroundUploadFgService).disableUploadService();
await ref.read(driftBackupProvider.notifier).cancel();
}

View File

@@ -79,7 +79,7 @@ class _ChangeExperiencePageState extends ConsumerState<ChangeExperiencePage> {
ref.read(readonlyModeProvider.notifier).setReadonlyMode(false);
await migrateStoreToIsar(ref.read(isarProvider), ref.read(driftProvider));
await ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
await ref.read(driftBackgroundUploadFgService).disable();
await ref.read(driftBackgroundUploadFgService).disableUploadService();
}
await IsarStoreRepository(ref.read(isarProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta);

View File

@@ -59,9 +59,9 @@ class BackgroundWorkerFgHostApi {
final String pigeonVar_messageChannelSuffix;
Future<void> enable() async {
Future<void> enableSyncWorker() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable$pigeonVar_messageChannelSuffix';
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableSyncWorker$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -82,9 +82,32 @@ class BackgroundWorkerFgHostApi {
}
}
Future<void> disable() async {
Future<void> enableUploadWorker(int callbackHandle) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$pigeonVar_messageChannelSuffix';
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[callbackHandle]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else {
return;
}
}
Future<void> disableUploadWorker() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disableUploadWorker$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -141,34 +164,13 @@ class BackgroundWorkerBgHostApi {
return;
}
}
Future<void> close() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.close$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else {
return;
}
}
}
abstract class BackgroundWorkerFlutterApi {
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
Future<void> onLocalSync(int? maxSeconds);
Future<void> onIosUpload(bool isRefresh, int? maxSeconds);
Future<void> onAndroidUpload();
@@ -181,6 +183,35 @@ abstract class BackgroundWorkerFlutterApi {
String messageChannelSuffix = '',
}) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
{
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onLocalSync$messageChannelSuffix',
pigeonChannelCodec,
binaryMessenger: binaryMessenger,
);
if (api == null) {
pigeonVar_channel.setMessageHandler(null);
} else {
pigeonVar_channel.setMessageHandler((Object? message) async {
assert(
message != null,
'Argument for dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onLocalSync was null.',
);
final List<Object?> args = (message as List<Object?>?)!;
final int? arg_maxSeconds = (args[0] as int?);
try {
await api.onLocalSync(arg_maxSeconds);
return wrapResponse(empty: true);
} on PlatformException catch (e) {
return wrapResponse(error: e);
} catch (e) {
return wrapResponse(
error: PlatformException(code: 'error', message: e.toString()),
);
}
});
}
}
{
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload$messageChannelSuffix',

View File

@@ -62,6 +62,11 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
return;
}
yield image;
} catch (e) {
evict();
if (!isCancelled) {
_log.severe('Error loading image', e);
}
} finally {
this.request = null;
}

View File

@@ -7,13 +7,11 @@ import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
with CancellableImageProviderMixin<RemoteThumbProvider> {
static final cacheManager = RemoteThumbnailCacheManager();
final String assetId;
RemoteThumbProvider({required this.assetId});
@@ -39,7 +37,6 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
final request = this.request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId),
headers: ApiService.getRequestHeaders(),
cacheManager: cacheManager,
);
return loadRequest(request, decode);
}
@@ -60,7 +57,6 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider>
with CancellableImageProviderMixin<RemoteFullImageProvider> {
static final cacheManager = RemoteThumbnailCacheManager();
final String assetId;
RemoteFullImageProvider({required this.assetId});
@@ -92,11 +88,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
}
final headers = ApiService.getRequestHeaders();
final request = this.request = RemoteImageRequest(
uri: getPreviewUrlForRemoteId(key.assetId),
headers: headers,
cacheManager: cacheManager,
);
final request = this.request = RemoteImageRequest(uri: getPreviewUrlForRemoteId(key.assetId), headers: headers);
yield* loadRequest(request, decode);
if (isCancelled) {

View File

@@ -2,9 +2,11 @@ import 'dart:ui';
const double kTimelineHeaderExtent = 80.0;
const Size kTimelineFixedTileExtent = Size.square(256);
const Size kThumbnailResolution = Size.square(320); // TODO: make the resolution vary based on actual tile size
const double kTimelineSpacing = 2.0;
const int kTimelineColumnCount = 3;
const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300);
const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800);
const Size kThumbnailResolution = Size.square(320); // TODO: make the resolution vary based on actual tile size
const kThumbnailDiskCacheSize = 1024 << 20; // 1GiB

View File

@@ -1,148 +1,25 @@
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
// ignore: implementation_imports
import 'package:flutter_cache_manager/src/cache_store.dart';
import 'package:logging/logging.dart';
import 'package:uuid/uuid.dart';
abstract class RemoteCacheManager extends CacheManager {
static final _log = Logger('RemoteCacheManager');
RemoteCacheManager.custom(super.config, CacheStore store)
// Unfortunately, CacheStore is not a public API
// ignore: invalid_use_of_visible_for_testing_member
: super.custom(cacheStore: store);
Future<void> putStreamedFile(
String url,
Stream<List<int>> source, {
String? key,
String? eTag,
Duration maxAge = const Duration(days: 30),
String fileExtension = 'file',
});
// Unlike `putFileStream`, this method handles request cancellation,
// does not make a (slow) DB call checking if the file is already cached,
// does not synchronously check if a file exists,
// and deletes the file on cancellation without making these checks again.
Future<void> putStreamedFileToStore(
CacheStore store,
String url,
Stream<List<int>> source, {
String? key,
String? eTag,
Duration maxAge = const Duration(days: 30),
String fileExtension = 'file',
}) async {
final path = '${const Uuid().v1()}.$fileExtension';
final file = await store.fileSystem.createFile(path);
final sink = file.openWrite();
try {
await source.listen(sink.add, cancelOnError: true).asFuture();
} catch (e) {
try {
await sink.close();
await file.delete();
} catch (e) {
_log.severe('Failed to delete incomplete cache file: $e');
}
return;
}
try {
await sink.flush();
await sink.close();
} catch (e) {
try {
await file.delete();
} catch (e) {
_log.severe('Failed to delete incomplete cache file: $e');
}
return;
}
final cacheObject = CacheObject(
url,
key: key,
relativePath: path,
validTill: DateTime.now().add(maxAge),
eTag: eTag,
);
try {
await store.putFile(cacheObject);
} catch (e) {
try {
await file.delete();
} catch (e) {
_log.severe('Failed to delete untracked cache file: $e');
}
}
}
}
class RemoteImageCacheManager extends RemoteCacheManager {
class RemoteImageCacheManager extends CacheManager {
static const key = 'remoteImageCacheKey';
static final RemoteImageCacheManager _instance = RemoteImageCacheManager._();
static final _config = Config(key, maxNrOfCacheObjects: 500, stalePeriod: const Duration(days: 30));
static final _store = CacheStore(_config);
factory RemoteImageCacheManager() {
return _instance;
}
RemoteImageCacheManager._() : super.custom(_config, _store);
@override
Future<void> putStreamedFile(
String url,
Stream<List<int>> source, {
String? key,
String? eTag,
Duration maxAge = const Duration(days: 30),
String fileExtension = 'file',
}) {
return putStreamedFileToStore(
_store,
url,
source,
key: key,
eTag: eTag,
maxAge: maxAge,
fileExtension: fileExtension,
);
}
RemoteImageCacheManager._() : super(_config);
}
/// The cache manager for full size images [ImmichRemoteImageProvider]
class RemoteThumbnailCacheManager extends RemoteCacheManager {
class RemoteThumbnailCacheManager extends CacheManager {
static const key = 'remoteThumbnailCacheKey';
static final RemoteThumbnailCacheManager _instance = RemoteThumbnailCacheManager._();
static final _config = Config(key, maxNrOfCacheObjects: 5000, stalePeriod: const Duration(days: 30));
static final _store = CacheStore(_config);
factory RemoteThumbnailCacheManager() {
return _instance;
}
RemoteThumbnailCacheManager._() : super.custom(_config, _store);
@override
Future<void> putStreamedFile(
String url,
Stream<List<int>> source, {
String? key,
String? eTag,
Duration maxAge = const Duration(days: 30),
String fileExtension = 'file',
}) {
return putStreamedFileToStore(
_store,
url,
source,
key: key,
eTag: eTag,
maxAge: maxAge,
fileExtension: fileExtension,
);
}
RemoteThumbnailCacheManager._() : super(_config);
}

View File

@@ -4,15 +4,16 @@ import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/utils/user_agent.dart';
class ApiService implements Authentication {
static final _client = const NetworkRepository().getHttpClient('api');
late ApiClient _apiClient;
late UsersApi usersApi;
@@ -50,6 +51,7 @@ class ApiService implements Authentication {
setEndpoint(String endpoint) {
_apiClient = ApiClient(basePath: endpoint, authentication: this);
_apiClient.client = _client;
_setUserAgentHeader();
if (_accessToken != null) {
setAccessToken(_accessToken!);
@@ -134,13 +136,11 @@ class ApiService implements Authentication {
}
Future<String> _getWellKnownEndpoint(String baseUrl) async {
final Client client = Client();
try {
var headers = {"Accept": "application/json"};
headers.addAll(getRequestHeaders());
final res = await client
final res = await _client
.get(Uri.parse("$baseUrl/.well-known/immich"), headers: headers)
.timeout(const Duration(seconds: 5));

View File

@@ -1,19 +0,0 @@
const int _maxMillisecondsSinceEpoch = 8640000000000000; // 275760-09-13
const int _minMillisecondsSinceEpoch = -62135596800000; // 0001-01-01
DateTime? tryFromSecondsSinceEpoch(int? secondsSinceEpoch) {
if (secondsSinceEpoch == null) {
return null;
}
final milliSeconds = secondsSinceEpoch * 1000;
if (milliSeconds < _minMillisecondsSinceEpoch || milliSeconds > _maxMillisecondsSinceEpoch) {
return null;
}
try {
return DateTime.fromMillisecondsSinceEpoch(milliSeconds);
} catch (e) {
return null;
}
}

View File

@@ -18,7 +18,7 @@ class AssetsApi {
/// checkBulkUpload
///
/// Checks if assets exist by checksums. This endpoint requires the `asset.upload` permission.
/// Checks if assets exist by checksums
///
/// Note: This method returns the HTTP [Response].
///
@@ -52,7 +52,7 @@ class AssetsApi {
/// checkBulkUpload
///
/// Checks if assets exist by checksums. This endpoint requires the `asset.upload` permission.
/// Checks if assets exist by checksums
///
/// Parameters:
///

View File

@@ -13,9 +13,13 @@ import 'package:pigeon/pigeon.dart';
)
@HostApi()
abstract class BackgroundWorkerFgHostApi {
void enable();
void enableSyncWorker();
void disable();
// Enables the background upload service with the given callback handle
void enableUploadWorker(int callbackHandle);
// Disables the background upload service
void disableUploadWorker();
}
@HostApi()
@@ -23,12 +27,14 @@ abstract class BackgroundWorkerBgHostApi {
// Called from the background flutter engine when it has bootstrapped and established the
// required platform channels to notify the native side to start the background upload
void onInitialized();
void close();
}
@FlutterApi()
abstract class BackgroundWorkerFlutterApi {
// Android & iOS: Called when the local sync is triggered
@async
void onLocalSync(int? maxSeconds);
// iOS Only: Called when the iOS background upload is triggered
@async
void onIosUpload(bool isRefresh, int? maxSeconds);

View File

@@ -337,6 +337,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.2"
cronet_http:
dependency: "direct main"
description:
name: cronet_http
sha256: "1b99ad5ae81aa9d2f12900e5f17d3681f3828629bb7f7fe7ad88076a34209840"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
crop_image:
dependency: "direct main"
description:
@@ -369,6 +377,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_http:
dependency: "direct main"
description:
name: cupertino_http
sha256: "72187f715837290a63479a5b0ae709f4fedad0ed6bd0441c275eceaa02d5abae"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
custom_lint:
dependency: "direct dev"
description:
@@ -899,10 +915,10 @@ packages:
dependency: "direct main"
description:
name: http
sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
url: "https://pub.dev"
source: hosted
version: "1.3.0"
version: "1.5.0"
http_multi_server:
dependency: transitive
description:
@@ -919,6 +935,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
http_profile:
dependency: transitive
description:
name: http_profile
sha256: "7e679e355b09aaee2ab5010915c932cce3f2d1c11c3b2dc177891687014ffa78"
url: "https://pub.dev"
source: hosted
version: "0.1.0"
image:
dependency: transitive
description:
@@ -1044,6 +1068,14 @@ packages:
url: "https://github.com/immich-app/isar"
source: git
version: "3.1.8"
jni:
dependency: transitive
description:
name: jni
sha256: d2c361082d554d4593c3012e26f6b188f902acd291330f13d6427641a92b3da1
url: "https://pub.dev"
source: hosted
version: "0.14.2"
js:
dependency: transitive
description:
@@ -1237,6 +1269,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.5.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "9f034ba1eeca53ddb339bc8f4813cb07336a849cd735559b60cdc068ecce2dc7"
url: "https://pub.dev"
source: hosted
version: "7.1.0"
octo_image:
dependency: "direct main"
description:

View File

@@ -90,6 +90,8 @@ dependencies:
# DB
drift: ^2.23.1
drift_flutter: ^0.2.4
cronet_http: ^1.5.0
cupertino_http: ^2.3.0
dev_dependencies:
flutter_test:

View File

@@ -1,58 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart';
void main() {
group('tryFromSecondsSinceEpoch', () {
test('returns null for null input', () {
final result = tryFromSecondsSinceEpoch(null);
expect(result, isNull);
});
test('returns null for value below minimum allowed range', () {
// _minMillisecondsSinceEpoch = -62135596800000
final seconds = -62135596800000 ~/ 1000 - 1; // One second before min allowed
final result = tryFromSecondsSinceEpoch(seconds);
expect(result, isNull);
});
test('returns null for value above maximum allowed range', () {
// _maxMillisecondsSinceEpoch = 8640000000000000
final seconds = 8640000000000000 ~/ 1000 + 1; // One second after max allowed
final result = tryFromSecondsSinceEpoch(seconds);
expect(result, isNull);
});
test('returns correct DateTime for minimum allowed value', () {
final seconds = -62135596800000 ~/ 1000; // Minimum allowed timestamp
final result = tryFromSecondsSinceEpoch(seconds);
expect(result, DateTime.fromMillisecondsSinceEpoch(-62135596800000));
});
test('returns correct DateTime for maximum allowed value', () {
final seconds = 8640000000000000 ~/ 1000; // Maximum allowed timestamp
final result = tryFromSecondsSinceEpoch(seconds);
expect(result, DateTime.fromMillisecondsSinceEpoch(8640000000000000));
});
test('returns correct DateTime for negative timestamp', () {
final seconds = -1577836800; // Dec 31, 1919 (pre-epoch)
final result = tryFromSecondsSinceEpoch(seconds);
expect(result, DateTime.fromMillisecondsSinceEpoch(-1577836800 * 1000));
});
test('returns correct DateTime for zero timestamp', () {
final seconds = 0; // Jan 1, 1970 (epoch)
final result = tryFromSecondsSinceEpoch(seconds);
expect(result, DateTime.fromMillisecondsSinceEpoch(0));
});
test('returns correct DateTime for recent timestamp', () {
final now = DateTime.now();
final seconds = now.millisecondsSinceEpoch ~/ 1000;
final result = tryFromSecondsSinceEpoch(seconds);
expect(result?.year, now.year);
expect(result?.month, now.month);
expect(result?.day, now.day);
});
});
}

View File

@@ -1855,7 +1855,7 @@
},
"/assets/bulk-upload-check": {
"post": {
"description": "Checks if assets exist by checksums. This endpoint requires the `asset.upload` permission.",
"description": "Checks if assets exist by checksums",
"operationId": "checkBulkUpload",
"parameters": [],
"requestBody": {
@@ -1894,8 +1894,7 @@
"summary": "checkBulkUpload",
"tags": [
"Assets"
],
"x-immich-permission": "asset.upload"
]
}
},
"/assets/device/{deviceId}": {

View File

@@ -1,5 +1,5 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:202509021104@sha256:47d38c94775332000a93fbbeca1c796687b2d2919e3c75b6e26ab8a65d1864f3 AS dev
FROM ghcr.io/immich-app/base-server-dev:202508191104@sha256:0608857ef682099c458f0fb319afdcaf09462bbb5670b6dcd3642029f12eee1c AS dev
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
@@ -77,7 +77,7 @@ RUN apt-get update \
RUN dart --disable-analytics
# production-builder-base image
FROM ghcr.io/immich-app/base-server-dev:202509021104@sha256:47d38c94775332000a93fbbeca1c796687b2d2919e3c75b6e26ab8a65d1864f3 AS prod-builder-base
FROM ghcr.io/immich-app/base-server-dev:202508191104@sha256:0608857ef682099c458f0fb319afdcaf09462bbb5670b6dcd3642029f12eee1c AS prod-builder-base
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp
@@ -115,7 +115,7 @@ RUN pnpm --filter @immich/sdk --filter @immich/cli --frozen-lockfile install &&
pnpm --filter @immich/cli --prod --no-optional deploy /output/cli-pruned
# prod base image
FROM ghcr.io/immich-app/base-server-prod:202509021104@sha256:84f3727cff75c623f79236cdd9a2b72c84f7665057f474851016f702c67157af
FROM ghcr.io/immich-app/base-server-prod:202508191104@sha256:4cce4119f5555fce5e383b681e4feea31956ceadb94cafcbcbbae2c7b94a1b62
WORKDIR /usr/src/app
ENV NODE_ENV=production \

View File

@@ -188,7 +188,7 @@ export class AssetMediaController {
* Checks if assets exist by checksums
*/
@Post('bulk-upload-check')
@Authenticated({ permission: Permission.AssetUpload })
@Authenticated()
@ApiOperation({
summary: 'checkBulkUpload',
description: 'Checks if assets exist by checksums',

View File

@@ -12,7 +12,7 @@ import { AuthRequest } from 'src/middleware/auth.guard';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { AssetMediaService } from 'src/services/asset-media.service';
import { ImmichFile, UploadFile, UploadFiles } from 'src/types';
import { asUploadRequest, mapToUploadFile } from 'src/utils/asset.util';
import { asRequest, mapToUploadFile } from 'src/utils/asset.util';
export function getFile(files: UploadFiles, property: 'assetData' | 'sidecarData') {
const file = files[property]?.[0];
@@ -99,21 +99,18 @@ export class FileUploadInterceptor implements NestInterceptor {
}
private fileFilter(request: AuthRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) {
return callbackify(() => this.assetService.canUploadFile(asUploadRequest(request, file)), callback);
return callbackify(() => this.assetService.canUploadFile(asRequest(request, file)), callback);
}
private filename(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
return callbackify(
() => this.assetService.getUploadFilename(asUploadRequest(request, file)),
() => this.assetService.getUploadFilename(asRequest(request, file)),
callback as Callback<string>,
);
}
private destination(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
return callbackify(
() => this.assetService.getUploadFolder(asUploadRequest(request, file)),
callback as Callback<string>,
);
return callbackify(() => this.assetService.getUploadFolder(asRequest(request, file)), callback as Callback<string>);
}
private handleFile(request: AuthRequest, file: Express.Multer.File, callback: Callback<Partial<ImmichFile>>) {

View File

@@ -25,7 +25,6 @@ const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
const uploadFile = {
nullAuth: {
auth: null,
body: {},
fieldName: UploadFieldName.ASSET_DATA,
file: {
uuid: 'random-uuid',
@@ -38,7 +37,6 @@ const uploadFile = {
filename: (fieldName: UploadFieldName, filename: string) => {
return {
auth: authStub.admin,
body: {},
fieldName,
file: {
uuid: 'random-uuid',
@@ -899,10 +897,7 @@ describe(AssetMediaService.name, () => {
describe('onUploadError', () => {
it('should queue a job to delete the uploaded file', async () => {
const request = {
body: {},
user: authStub.user1,
} as AuthRequest;
const request = { user: authStub.user1 } as AuthRequest;
const file = {
fieldname: UploadFieldName.ASSET_DATA,

View File

@@ -24,14 +24,20 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { AssetStatus, AssetType, AssetVisibility, CacheControl, JobName, Permission, StorageFolder } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard';
import { BaseService } from 'src/services/base.service';
import { UploadFile, UploadRequest } from 'src/types';
import { UploadFile } from 'src/types';
import { requireUploadAccess } from 'src/utils/access';
import { asUploadRequest, getAssetFiles, onBeforeLink } from 'src/utils/asset.util';
import { asRequest, getAssetFiles, onBeforeLink } from 'src/utils/asset.util';
import { isAssetChecksumConstraint } from 'src/utils/database';
import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types';
import { fromChecksum } from 'src/utils/request';
interface UploadRequest {
auth: AuthDto | null;
fieldName: UploadFieldName;
file: UploadFile;
}
export interface AssetMediaRedirectResponse {
targetSize: AssetMediaSize | 'original';
}
@@ -83,15 +89,15 @@ export class AssetMediaService extends BaseService {
throw new BadRequestException(`Unsupported file type ${filename}`);
}
getUploadFilename({ auth, fieldName, file, body }: UploadRequest): string {
getUploadFilename({ auth, fieldName, file }: UploadRequest): string {
requireUploadAccess(auth);
const extension = extname(body.filename || file.originalName);
const originalExtension = extname(file.originalName);
const lookup = {
[UploadFieldName.ASSET_DATA]: extension,
[UploadFieldName.ASSET_DATA]: originalExtension,
[UploadFieldName.SIDECAR_DATA]: '.xmp',
[UploadFieldName.PROFILE_DATA]: extension,
[UploadFieldName.PROFILE_DATA]: originalExtension,
};
return sanitize(`${file.uuid}${lookup[fieldName]}`);
@@ -111,8 +117,8 @@ export class AssetMediaService extends BaseService {
}
async onUploadError(request: AuthRequest, file: Express.Multer.File) {
const uploadFilename = this.getUploadFilename(asUploadRequest(request, file));
const uploadFolder = this.getUploadFolder(asUploadRequest(request, file));
const uploadFilename = this.getUploadFilename(asRequest(request, file));
const uploadFolder = this.getUploadFolder(asRequest(request, file));
const uploadPath = `${uploadFolder}/${uploadFilename}`;
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [uploadPath] } });

View File

@@ -1,7 +1,5 @@
import { SystemConfig } from 'src/config';
import { VECTOR_EXTENSIONS } from 'src/constants';
import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
AssetMetadataKey,
AssetOrder,
@@ -410,16 +408,6 @@ export interface UploadFile {
size: number;
}
export type UploadRequest = {
auth: AuthDto | null;
fieldName: UploadFieldName;
file: UploadFile;
body: {
filename?: string;
[key: string]: unknown;
};
};
export interface UploadFiles {
assetData: ImmichFile[];
sidecarData: ImmichFile[];

View File

@@ -10,7 +10,7 @@ import { AccessRepository } from 'src/repositories/access.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { IBulkAsset, ImmichFile, UploadFile, UploadRequest } from 'src/types';
import { IBulkAsset, ImmichFile, UploadFile } from 'src/types';
import { checkAccess } from 'src/utils/access';
export const getAssetFile = (files: AssetFile[], type: AssetFileType | GeneratedImageType) => {
@@ -190,10 +190,9 @@ export function mapToUploadFile(file: ImmichFile): UploadFile {
};
}
export const asUploadRequest = (request: AuthRequest, file: Express.Multer.File): UploadRequest => {
export const asRequest = (request: AuthRequest, file: Express.Multer.File) => {
return {
auth: request.user || null,
body: request.body,
fieldName: file.fieldname as UploadFieldName,
file: mapToUploadFile(file as ImmichFile),
};

View File

@@ -34,7 +34,8 @@ type SendFile = Parameters<Response['sendFile']>;
type SendFileOptions = SendFile[1];
const cacheControlHeaders: Record<CacheControl, string | null> = {
[CacheControl.PrivateWithCache]: 'private, max-age=86400, no-transform',
[CacheControl.PrivateWithCache]:
'private, max-age=86400, no-transform, stale-while-revalidate=2592000, stale-if-error=2592000',
[CacheControl.PrivateWithoutCache]: 'private, no-cache, no-transform',
[CacheControl.None]: null, // falsy value to prevent adding Cache-Control header
};