mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge branch 'main' into feat/search-filter-album/web
This commit is contained in:
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"flutter": "3.32.6"
|
||||
"flutter": "3.35.4"
|
||||
}
|
||||
Vendored
+5
-1
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"dart.flutterSdkPath": ".fvm/versions/3.32.6",
|
||||
"dart.flutterSdkPath": ".fvm/versions/3.35.4",
|
||||
"dart.lineLength": 120,
|
||||
"[dart]": {
|
||||
"editor.rulers": [120]
|
||||
},
|
||||
"search.exclude": {
|
||||
"**/.fvm": true
|
||||
},
|
||||
|
||||
+1
-1
@@ -84,4 +84,4 @@ Below is how your code needs to be structured:
|
||||
|
||||
## Contributing
|
||||
|
||||
Please refer to the [architecture](https://immich.app/docs/developer/architecture/) for contributing to the mobile app!
|
||||
Please refer to the [architecture](https://docs.immich.app/developer/architecture/) for contributing to the mobile app!
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
formatter:
|
||||
page_width: 120
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
@@ -40,8 +43,9 @@ analyzer:
|
||||
- lib/**/*.g.dart
|
||||
- lib/**/*.drift.dart
|
||||
|
||||
plugins:
|
||||
- custom_lint
|
||||
# TODO: Re-enable after upgrading custom_lint
|
||||
# plugins:
|
||||
# - custom_lint
|
||||
|
||||
custom_lint:
|
||||
debug: true
|
||||
@@ -78,6 +82,7 @@ custom_lint:
|
||||
# acceptable exceptions for the time being (until Isar is fully replaced)
|
||||
- lib/providers/app_life_cycle.provider.dart
|
||||
- integration_test/test_utils/general_helper.dart
|
||||
- lib/domain/services/background_worker.service.dart
|
||||
- lib/main.dart
|
||||
- lib/pages/album/album_asset_selection.page.dart
|
||||
- lib/routing/router.dart
|
||||
@@ -130,6 +135,13 @@ custom_lint:
|
||||
|
||||
dart_code_metrics:
|
||||
rules:
|
||||
- banned-usage:
|
||||
entries:
|
||||
- name: debugPrint
|
||||
description: Use dPrint instead of debugPrint for proper tree-shaking in release builds.
|
||||
exclude-paths:
|
||||
- 'lib/utils/debug_print.dart'
|
||||
severity: perf
|
||||
# All rules from "recommended" preset
|
||||
# Show potential errors
|
||||
# - avoid-cascade-after-if-null
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
cmake_minimum_required(VERSION 3.12)
|
||||
|
||||
set(CMAKE_C_STANDARD 17)
|
||||
set(CMAKE_C_STANDARD_REQUIRED ON)
|
||||
|
||||
project(native_buffer LANGUAGES C)
|
||||
|
||||
add_library(native_buffer SHARED
|
||||
src/main/cpp/native_buffer.c
|
||||
)
|
||||
@@ -72,20 +72,6 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions "default"
|
||||
productFlavors {
|
||||
production {
|
||||
dimension "default"
|
||||
applicationId "app.alextran.immich"
|
||||
}
|
||||
|
||||
beta {
|
||||
dimension "default"
|
||||
applicationId "app.alextran.immich.beta"
|
||||
versionNameSuffix "-BETA"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix '.debug'
|
||||
@@ -97,6 +83,12 @@ android {
|
||||
}
|
||||
}
|
||||
namespace 'app.alextran.immich'
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path "CMakeLists.txt"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<application android:label="Immich Beta" tools:replace="android:label" />
|
||||
</manifest>
|
||||
@@ -27,7 +27,9 @@
|
||||
|
||||
<application android:label="Immich" android:name=".ImmichApp" android:usesCleartextTraffic="true"
|
||||
android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="true"
|
||||
android:largeHeap="true" android:enableOnBackInvokedCallback="false">
|
||||
android:largeHeap="true" android:enableOnBackInvokedCallback="false" android:allowBackup="false">
|
||||
|
||||
<profileable android:shell="true" />
|
||||
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
#include <jni.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_allocateNative(
|
||||
JNIEnv *env, jclass clazz, jint size) {
|
||||
void *ptr = malloc(size);
|
||||
return (jlong) ptr;
|
||||
}
|
||||
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_app_alextran_immich_images_ThumbnailsImpl_allocateNative(
|
||||
JNIEnv *env, jclass clazz, jint size) {
|
||||
void *ptr = malloc(size);
|
||||
return (jlong) ptr;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_freeNative(
|
||||
JNIEnv *env, jclass clazz, jlong address) {
|
||||
free((void *) address);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_app_alextran_immich_images_ThumbnailsImpl_freeNative(
|
||||
JNIEnv *env, jclass clazz, jlong address) {
|
||||
free((void *) address);
|
||||
}
|
||||
|
||||
JNIEXPORT jobject JNICALL
|
||||
Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_wrapAsBuffer(
|
||||
JNIEnv *env, jclass clazz, jlong address, jint capacity) {
|
||||
return (*env)->NewDirectByteBuffer(env, (void *) address, capacity);
|
||||
}
|
||||
|
||||
JNIEXPORT jobject JNICALL
|
||||
Java_app_alextran_immich_images_ThumbnailsImpl_wrapAsBuffer(
|
||||
JNIEnv *env, jclass clazz, jlong address, jint capacity) {
|
||||
return (*env)->NewDirectByteBuffer(env, (void *) address, capacity);
|
||||
}
|
||||
@@ -1,7 +1,20 @@
|
||||
package app.alextran.immich
|
||||
|
||||
import android.content.Context
|
||||
import com.bumptech.glide.GlideBuilder
|
||||
import com.bumptech.glide.annotation.GlideModule
|
||||
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPoolAdapter
|
||||
import com.bumptech.glide.load.engine.cache.DiskCacheAdapter
|
||||
import com.bumptech.glide.load.engine.cache.MemoryCacheAdapter
|
||||
import com.bumptech.glide.module.AppGlideModule
|
||||
|
||||
@GlideModule
|
||||
class AppGlideModule : AppGlideModule()
|
||||
class AppGlideModule : AppGlideModule() {
|
||||
override fun applyOptions(context: Context, builder: GlideBuilder) {
|
||||
super.applyOptions(context, builder)
|
||||
// disable caching as this is already done on the Flutter side
|
||||
builder.setMemoryCache(MemoryCacheAdapter())
|
||||
builder.setDiskCache(DiskCacheAdapter.Factory())
|
||||
builder.setBitmapPool(BitmapPoolAdapter())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
package app.alextran.immich
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
import app.alextran.immich.background.BackgroundEngineLock
|
||||
import app.alextran.immich.background.BackgroundWorkerApiImpl
|
||||
|
||||
class ImmichApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val config = Configuration.Builder().build()
|
||||
WorkManager.initialize(this, config)
|
||||
// always start BackupWorker after WorkManager init; this fixes the following bug:
|
||||
// After the process is killed (by user or system), the first trigger (taking a new picture) is lost.
|
||||
// Thus, the BackupWorker is not started. If the system kills the process after each initialization
|
||||
// (because of low memory etc.), the backup is never performed.
|
||||
// As a workaround, we also run a backup check when initializing the application
|
||||
ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0)
|
||||
}
|
||||
}
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val config = Configuration.Builder().build()
|
||||
WorkManager.initialize(this, config)
|
||||
// always start BackupWorker after WorkManager init; this fixes the following bug:
|
||||
// After the process is killed (by user or system), the first trigger (taking a new picture) is lost.
|
||||
// Thus, the BackupWorker is not started. If the system kills the process after each initialization
|
||||
// (because of low memory etc.), the backup is never performed.
|
||||
// As a workaround, we also run a backup check when initializing the application
|
||||
|
||||
ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0)
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
// We can only check the engine count and not the status of the lock here,
|
||||
// as the previous start might have been killed without unlocking.
|
||||
if (BackgroundEngineLock.connectEngines > 0) return@postDelayed
|
||||
BackgroundWorkerApiImpl.enqueueBackgroundWorker(this)
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
package app.alextran.immich
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.ext.SdkExtensions
|
||||
import androidx.annotation.NonNull
|
||||
import app.alextran.immich.background.BackgroundEngineLock
|
||||
import app.alextran.immich.background.BackgroundWorkerApiImpl
|
||||
import app.alextran.immich.background.BackgroundWorkerFgHostApi
|
||||
import app.alextran.immich.background.BackgroundWorkerLockApi
|
||||
import app.alextran.immich.connectivity.ConnectivityApi
|
||||
import app.alextran.immich.connectivity.ConnectivityApiImpl
|
||||
import app.alextran.immich.images.ThumbnailApi
|
||||
import app.alextran.immich.images.ThumbnailsImpl
|
||||
import app.alextran.immich.sync.NativeSyncApi
|
||||
import app.alextran.immich.sync.NativeSyncApiImpl26
|
||||
import app.alextran.immich.sync.NativeSyncApiImpl30
|
||||
@@ -10,18 +18,30 @@ import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
|
||||
class MainActivity : FlutterFragmentActivity() {
|
||||
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
flutterEngine.plugins.add(BackgroundServicePlugin())
|
||||
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
|
||||
// No need to set up method channel here as it's now handled in the plugin
|
||||
registerPlugins(this, flutterEngine)
|
||||
}
|
||||
|
||||
val nativeSyncApiImpl =
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || SdkExtensions.getExtensionVersion(Build.VERSION_CODES.R) < 1) {
|
||||
NativeSyncApiImpl26(this)
|
||||
} else {
|
||||
NativeSyncApiImpl30(this)
|
||||
}
|
||||
NativeSyncApi.setUp(flutterEngine.dartExecutor.binaryMessenger, nativeSyncApiImpl)
|
||||
companion object {
|
||||
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
|
||||
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
||||
val backgroundEngineLockImpl = BackgroundEngineLock(ctx)
|
||||
BackgroundWorkerLockApi.setUp(messenger, backgroundEngineLockImpl)
|
||||
val nativeSyncApiImpl =
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || SdkExtensions.getExtensionVersion(Build.VERSION_CODES.R) < 1) {
|
||||
NativeSyncApiImpl26(ctx)
|
||||
} else {
|
||||
NativeSyncApiImpl30(ctx)
|
||||
}
|
||||
NativeSyncApi.setUp(messenger, nativeSyncApiImpl)
|
||||
ThumbnailApi.setUp(messenger, ThumbnailsImpl(ctx))
|
||||
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
|
||||
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
|
||||
|
||||
flutterEngine.plugins.add(BackgroundServicePlugin())
|
||||
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
|
||||
flutterEngine.plugins.add(backgroundEngineLockImpl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package app.alextran.immich.background
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
private const val TAG = "BackgroundEngineLock"
|
||||
|
||||
class BackgroundEngineLock(context: Context) : BackgroundWorkerLockApi, FlutterPlugin {
|
||||
private val ctx: Context = context.applicationContext
|
||||
|
||||
companion object {
|
||||
|
||||
private var engineCount = AtomicInteger(0)
|
||||
|
||||
val connectEngines: Int
|
||||
get() = engineCount.get()
|
||||
|
||||
private fun checkAndEnforceBackgroundLock(ctx: Context) {
|
||||
// work manager task is running while the main app is opened, cancel the worker
|
||||
if (BackgroundWorkerPreferences(ctx).isLocked() &&
|
||||
connectEngines > 1 &&
|
||||
BackgroundWorkerApiImpl.isBackgroundWorkerRunning()
|
||||
) {
|
||||
Log.i(TAG, "Background worker is locked, cancelling the background worker")
|
||||
BackgroundWorkerApiImpl.cancelBackgroundWorker(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun lock() {
|
||||
BackgroundWorkerPreferences(ctx).setLocked(true)
|
||||
checkAndEnforceBackgroundLock(ctx)
|
||||
Log.i(TAG, "Background worker is locked")
|
||||
}
|
||||
|
||||
override fun unlock() {
|
||||
BackgroundWorkerPreferences(ctx).setLocked(false)
|
||||
Log.i(TAG, "Background worker is unlocked")
|
||||
}
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
checkAndEnforceBackgroundLock(binding.applicationContext)
|
||||
engineCount.incrementAndGet()
|
||||
Log.i(TAG, "Flutter engine attached. Attached Engines count: $engineCount")
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
engineCount.decrementAndGet()
|
||||
Log.i(TAG, "Flutter engine detached. Attached Engines count: $engineCount")
|
||||
}
|
||||
}
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||
|
||||
package app.alextran.immich.background
|
||||
|
||||
import android.util.Log
|
||||
import io.flutter.plugin.common.BasicMessageChannel
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MessageCodec
|
||||
import io.flutter.plugin.common.StandardMethodCodec
|
||||
import io.flutter.plugin.common.StandardMessageCodec
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
private object BackgroundWorkerPigeonUtils {
|
||||
|
||||
fun createConnectionError(channelName: String): FlutterError {
|
||||
return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "") }
|
||||
|
||||
fun wrapResult(result: Any?): List<Any?> {
|
||||
return listOf(result)
|
||||
}
|
||||
|
||||
fun wrapError(exception: Throwable): List<Any?> {
|
||||
return if (exception is FlutterError) {
|
||||
listOf(
|
||||
exception.code,
|
||||
exception.message,
|
||||
exception.details
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
exception.javaClass.simpleName,
|
||||
exception.toString(),
|
||||
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
|
||||
)
|
||||
}
|
||||
}
|
||||
fun deepEquals(a: Any?, b: Any?): Boolean {
|
||||
if (a is ByteArray && b is ByteArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is IntArray && b is IntArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is LongArray && b is LongArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is DoubleArray && b is DoubleArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is Array<*> && b is Array<*>) {
|
||||
return a.size == b.size &&
|
||||
a.indices.all{ deepEquals(a[it], b[it]) }
|
||||
}
|
||||
if (a is List<*> && b is List<*>) {
|
||||
return a.size == b.size &&
|
||||
a.indices.all{ deepEquals(a[it], b[it]) }
|
||||
}
|
||||
if (a is Map<*, *> && b is Map<*, *>) {
|
||||
return a.size == b.size && a.all {
|
||||
(b as Map<Any?, Any?>).containsKey(it.key) &&
|
||||
deepEquals(it.value, b[it.key])
|
||||
}
|
||||
}
|
||||
return a == b
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for passing custom error details to Flutter via a thrown PlatformException.
|
||||
* @property code The error code.
|
||||
* @property message The error message.
|
||||
* @property details The error details. Must be a datatype supported by the api codec.
|
||||
*/
|
||||
class FlutterError (
|
||||
val code: String,
|
||||
override val message: String? = null,
|
||||
val details: Any? = null
|
||||
) : Throwable()
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class BackgroundWorkerSettings (
|
||||
val requiresCharging: Boolean,
|
||||
val minimumDelaySeconds: Long
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): BackgroundWorkerSettings {
|
||||
val requiresCharging = pigeonVar_list[0] as Boolean
|
||||
val minimumDelaySeconds = pigeonVar_list[1] as Long
|
||||
return BackgroundWorkerSettings(requiresCharging, minimumDelaySeconds)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
requiresCharging,
|
||||
minimumDelaySeconds,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other !is BackgroundWorkerSettings) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
return BackgroundWorkerPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||
|
||||
override fun hashCode(): Int = toList().hashCode()
|
||||
}
|
||||
private open class BackgroundWorkerPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return when (type) {
|
||||
129.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
BackgroundWorkerSettings.fromList(it)
|
||||
}
|
||||
}
|
||||
else -> super.readValueOfType(type, buffer)
|
||||
}
|
||||
}
|
||||
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||
when (value) {
|
||||
is BackgroundWorkerSettings -> {
|
||||
stream.write(129)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface BackgroundWorkerFgHostApi {
|
||||
fun enable()
|
||||
fun saveNotificationMessage(title: String, body: String)
|
||||
fun configure(settings: BackgroundWorkerSettings)
|
||||
fun disable()
|
||||
|
||||
companion object {
|
||||
/** The codec used by BackgroundWorkerFgHostApi. */
|
||||
val codec: MessageCodec<Any?> by lazy {
|
||||
BackgroundWorkerPigeonCodec()
|
||||
}
|
||||
/** Sets up an instance of `BackgroundWorkerFgHostApi` to handle messages through the `binaryMessenger`. */
|
||||
@JvmOverloads
|
||||
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)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
api.enable()
|
||||
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.saveNotificationMessage$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val titleArg = args[0] as String
|
||||
val bodyArg = args[1] as String
|
||||
val wrapped: List<Any?> = try {
|
||||
api.saveNotificationMessage(titleArg, bodyArg)
|
||||
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.configure$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val settingsArg = args[0] as BackgroundWorkerSettings
|
||||
val wrapped: List<Any?> = try {
|
||||
api.configure(settingsArg)
|
||||
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.disable$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
api.disable()
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
BackgroundWorkerPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/** 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. */
|
||||
val codec: MessageCodec<Any?> by lazy {
|
||||
BackgroundWorkerPigeonCodec()
|
||||
}
|
||||
/** Sets up an instance of `BackgroundWorkerBgHostApi` to handle messages through the `binaryMessenger`. */
|
||||
@JvmOverloads
|
||||
fun setUp(binaryMessenger: BinaryMessenger, api: BackgroundWorkerBgHostApi?, messageChannelSuffix: String = "") {
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.onInitialized$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
api.onInitialized()
|
||||
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.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */
|
||||
class BackgroundWorkerFlutterApi(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") {
|
||||
companion object {
|
||||
/** The codec used by BackgroundWorkerFlutterApi. */
|
||||
val codec: MessageCodec<Any?> by lazy {
|
||||
BackgroundWorkerPigeonCodec()
|
||||
}
|
||||
}
|
||||
fun onIosUpload(isRefreshArg: Boolean, maxSecondsArg: Long?, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(isRefreshArg, 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 onAndroidUpload(callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onAndroidUpload$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(null) {
|
||||
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 cancel(callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.cancel$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(null) {
|
||||
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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
package app.alextran.immich.background
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import app.alextran.immich.MainActivity
|
||||
import app.alextran.immich.R
|
||||
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.FlutterEngineCache
|
||||
import io.flutter.embedding.engine.dart.DartExecutor
|
||||
import io.flutter.embedding.engine.loader.FlutterLoader
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private const val TAG = "BackgroundWorker"
|
||||
|
||||
class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||
ListenableWorker(context, params), BackgroundWorkerBgHostApi {
|
||||
private val ctx: Context = context.applicationContext
|
||||
|
||||
/// The Flutter loader that loads the native Flutter library and resources.
|
||||
/// This must be initialized before starting the Flutter engine.
|
||||
private var loader: FlutterLoader = FlutterInjector.instance().flutterLoader()
|
||||
|
||||
/// The Flutter engine created specifically for background execution.
|
||||
/// This is a separate instance from the main Flutter engine that handles the UI.
|
||||
/// It operates in its own isolate and doesn't share memory with the main engine.
|
||||
/// Must be properly started, registered, and torn down during background execution.
|
||||
private var engine: FlutterEngine? = null
|
||||
|
||||
// Used to call methods on the flutter side
|
||||
private var flutterApi: BackgroundWorkerFlutterApi? = null
|
||||
|
||||
/// Result returned when the background task completes. This is used to signal
|
||||
/// to the WorkManager that the task has finished, either successfully or with failure.
|
||||
private val completionHandler: SettableFuture<Result> = SettableFuture.create()
|
||||
|
||||
/// Flag to track whether the background task has completed to prevent duplicate completions
|
||||
private var isComplete = false
|
||||
|
||||
private val notificationManager =
|
||||
ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
private var foregroundFuture: ListenableFuture<Void>? = null
|
||||
|
||||
companion object {
|
||||
private const val NOTIFICATION_CHANNEL_ID = "immich::background_worker::notif"
|
||||
private const val NOTIFICATION_ID = 100
|
||||
}
|
||||
|
||||
override fun startWork(): ListenableFuture<Result> {
|
||||
Log.i(TAG, "Starting background upload worker")
|
||||
|
||||
if (!loader.initialized()) {
|
||||
loader.startInitialization(ctx)
|
||||
}
|
||||
|
||||
val notificationChannel = NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID,
|
||||
NOTIFICATION_CHANNEL_ID,
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
notificationManager.createNotificationChannel(notificationChannel)
|
||||
val notificationConfig = BackgroundWorkerPreferences(ctx).getNotificationConfig()
|
||||
showNotification(notificationConfig.first, notificationConfig.second)
|
||||
|
||||
loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
|
||||
engine = FlutterEngine(ctx)
|
||||
FlutterEngineCache.getInstance().put(BackgroundWorkerApiImpl.ENGINE_CACHE_KEY, engine!!)
|
||||
|
||||
// Register custom plugins
|
||||
MainActivity.registerPlugins(ctx, engine!!)
|
||||
flutterApi =
|
||||
BackgroundWorkerFlutterApi(binaryMessenger = engine!!.dartExecutor.binaryMessenger)
|
||||
BackgroundWorkerBgHostApi.setUp(
|
||||
binaryMessenger = engine!!.dartExecutor.binaryMessenger,
|
||||
api = this
|
||||
)
|
||||
|
||||
engine!!.dartExecutor.executeDartEntrypoint(
|
||||
DartExecutor.DartEntrypoint(
|
||||
loader.findAppBundlePath(),
|
||||
"package:immich_mobile/domain/services/background_worker.service.dart",
|
||||
"backgroundSyncNativeEntrypoint"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return completionHandler
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the Flutter side when it has finished initialization and is ready to receive commands.
|
||||
* Routes the appropriate task type (refresh or processing) to the corresponding Flutter method.
|
||||
* This method acts as a bridge between the native Android background task system and Flutter.
|
||||
*/
|
||||
override fun onInitialized() {
|
||||
flutterApi?.onAndroidUpload { handleHostResult(it) }
|
||||
}
|
||||
|
||||
// TODO: Move this to a separate NotificationManager class
|
||||
private fun showNotification(title: String, content: String) {
|
||||
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.notification_icon)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setOngoing(true)
|
||||
.setTicker(title)
|
||||
.setContentTitle(title)
|
||||
.setContentText(content)
|
||||
.build()
|
||||
|
||||
if (isIgnoringBatteryOptimizations()) {
|
||||
foregroundFuture = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
setForegroundAsync(
|
||||
ForegroundInfo(
|
||||
NOTIFICATION_ID,
|
||||
notification,
|
||||
FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
)
|
||||
)
|
||||
} else {
|
||||
setForegroundAsync(ForegroundInfo(NOTIFICATION_ID, notification))
|
||||
}
|
||||
} else {
|
||||
notificationManager.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (isComplete) {
|
||||
return
|
||||
}
|
||||
|
||||
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
|
||||
if (flutterApi != null) {
|
||||
flutterApi?.cancel {
|
||||
complete(Result.failure())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
waitForForegroundPromotion()
|
||||
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
complete(Result.failure())
|
||||
}, 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
|
||||
}
|
||||
|
||||
result.fold(
|
||||
onSuccess = { _ -> complete(Result.success()) },
|
||||
onFailure = { _ -> onStopped() }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up resources by destroying the Flutter engine context and invokes the completion handler.
|
||||
* This method ensures that the background task is marked as complete, releases the Flutter engine,
|
||||
* and notifies the caller of the task's success or failure. This is the final step in the
|
||||
* background task lifecycle and should only be called once per task instance.
|
||||
*
|
||||
* - Parameter success: Indicates whether the background task completed successfully
|
||||
*/
|
||||
private fun complete(success: Result) {
|
||||
Log.d(TAG, "About to complete BackupWorker with result: $success")
|
||||
isComplete = true
|
||||
engine?.destroy()
|
||||
engine = null
|
||||
flutterApi = null
|
||||
notificationManager.cancel(NOTIFICATION_ID)
|
||||
FlutterEngineCache.getInstance().remove(BackgroundWorkerApiImpl.ENGINE_CACHE_KEY)
|
||||
waitForForegroundPromotion()
|
||||
completionHandler.set(success)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the app is ignoring battery optimizations
|
||||
*/
|
||||
private fun isIgnoringBatteryOptimizations(): Boolean {
|
||||
val powerManager = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
return powerManager.isIgnoringBatteryOptimizations(ctx.packageName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls to setForegroundAsync() that do not complete before completion of a ListenableWorker will signal an IllegalStateException
|
||||
* https://android-review.googlesource.com/c/platform/frameworks/support/+/1262743
|
||||
* Wait for a short period of time for the foreground promotion to complete before completing the worker
|
||||
*/
|
||||
private fun waitForForegroundPromotion() {
|
||||
val foregroundFuture = this.foregroundFuture
|
||||
if (foregroundFuture != null && !foregroundFuture.isCancelled && !foregroundFuture.isDone) {
|
||||
try {
|
||||
foregroundFuture.get(500, TimeUnit.MILLISECONDS)
|
||||
} catch (e: Exception) {
|
||||
// ignored, there is nothing to be done
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package app.alextran.immich.background
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import io.flutter.embedding.engine.FlutterEngineCache
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private const val TAG = "BackgroundWorkerApiImpl"
|
||||
|
||||
class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
||||
private val ctx: Context = context.applicationContext
|
||||
|
||||
override fun enable() {
|
||||
enqueueMediaObserver(ctx)
|
||||
}
|
||||
|
||||
override fun saveNotificationMessage(title: String, body: String) {
|
||||
BackgroundWorkerPreferences(ctx).updateNotificationConfig(title, body)
|
||||
}
|
||||
|
||||
override fun configure(settings: BackgroundWorkerSettings) {
|
||||
BackgroundWorkerPreferences(ctx).updateSettings(settings)
|
||||
enqueueMediaObserver(ctx)
|
||||
}
|
||||
|
||||
override fun disable() {
|
||||
WorkManager.getInstance(ctx).apply {
|
||||
cancelUniqueWork(OBSERVER_WORKER_NAME)
|
||||
cancelUniqueWork(BACKGROUND_WORKER_NAME)
|
||||
}
|
||||
Log.i(TAG, "Cancelled background upload tasks")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1"
|
||||
private const val OBSERVER_WORKER_NAME = "immich/MediaObserverV1"
|
||||
const val ENGINE_CACHE_KEY = "immich::background_worker::engine"
|
||||
|
||||
|
||||
fun enqueueMediaObserver(ctx: Context) {
|
||||
val settings = BackgroundWorkerPreferences(ctx).getSettings()
|
||||
val constraints = Constraints.Builder().apply {
|
||||
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(settings.minimumDelaySeconds, TimeUnit.SECONDS)
|
||||
setTriggerContentMaxDelay(settings.minimumDelaySeconds * 10, TimeUnit.SECONDS)
|
||||
setRequiresCharging(settings.requiresCharging)
|
||||
}.build()
|
||||
|
||||
val work = OneTimeWorkRequest.Builder(MediaObserver::class.java)
|
||||
.setConstraints(constraints)
|
||||
.build()
|
||||
WorkManager.getInstance(ctx)
|
||||
.enqueueUniqueWork(OBSERVER_WORKER_NAME, ExistingWorkPolicy.REPLACE, work)
|
||||
|
||||
Log.i(
|
||||
TAG,
|
||||
"Enqueued media observer worker with name: $OBSERVER_WORKER_NAME and settings: $settings"
|
||||
)
|
||||
}
|
||||
|
||||
fun enqueueBackgroundWorker(ctx: Context) {
|
||||
val constraints = Constraints.Builder().setRequiresBatteryNotLow(true).build()
|
||||
|
||||
val work = OneTimeWorkRequest.Builder(BackgroundWorker::class.java)
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
|
||||
.build()
|
||||
WorkManager.getInstance(ctx)
|
||||
.enqueueUniqueWork(BACKGROUND_WORKER_NAME, ExistingWorkPolicy.KEEP, work)
|
||||
|
||||
Log.i(TAG, "Enqueued background worker with name: $BACKGROUND_WORKER_NAME")
|
||||
}
|
||||
|
||||
fun isBackgroundWorkerRunning(): Boolean {
|
||||
// Easier to check if the engine is cached as we always cache the engine when starting the worker
|
||||
// and remove it when the worker is finished
|
||||
return FlutterEngineCache.getInstance().get(ENGINE_CACHE_KEY) != null
|
||||
}
|
||||
|
||||
fun cancelBackgroundWorker(ctx: Context) {
|
||||
WorkManager.getInstance(ctx).cancelUniqueWork(BACKGROUND_WORKER_NAME)
|
||||
FlutterEngineCache.getInstance().remove(ENGINE_CACHE_KEY)
|
||||
|
||||
Log.i(TAG, "Cancelled background upload task")
|
||||
}
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||
|
||||
package app.alextran.immich.background
|
||||
|
||||
import android.util.Log
|
||||
import io.flutter.plugin.common.BasicMessageChannel
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MessageCodec
|
||||
import io.flutter.plugin.common.StandardMethodCodec
|
||||
import io.flutter.plugin.common.StandardMessageCodec
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
private object BackgroundWorkerLockPigeonUtils {
|
||||
|
||||
fun wrapResult(result: Any?): List<Any?> {
|
||||
return listOf(result)
|
||||
}
|
||||
|
||||
fun wrapError(exception: Throwable): List<Any?> {
|
||||
return if (exception is FlutterError) {
|
||||
listOf(
|
||||
exception.code,
|
||||
exception.message,
|
||||
exception.details
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
exception.javaClass.simpleName,
|
||||
exception.toString(),
|
||||
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
private open class BackgroundWorkerLockPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return super.readValueOfType(type, buffer)
|
||||
}
|
||||
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||
super.writeValue(stream, value)
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface BackgroundWorkerLockApi {
|
||||
fun lock()
|
||||
fun unlock()
|
||||
|
||||
companion object {
|
||||
/** The codec used by BackgroundWorkerLockApi. */
|
||||
val codec: MessageCodec<Any?> by lazy {
|
||||
BackgroundWorkerLockPigeonCodec()
|
||||
}
|
||||
/** Sets up an instance of `BackgroundWorkerLockApi` to handle messages through the `binaryMessenger`. */
|
||||
@JvmOverloads
|
||||
fun setUp(binaryMessenger: BinaryMessenger, api: BackgroundWorkerLockApi?, messageChannelSuffix: String = "") {
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerLockApi.lock$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
api.lock()
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
BackgroundWorkerLockPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerLockApi.unlock$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
api.unlock()
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
BackgroundWorkerLockPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package app.alextran.immich.background
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
|
||||
class BackgroundWorkerPreferences(private val ctx: Context) {
|
||||
companion object {
|
||||
const val SHARED_PREF_NAME = "Immich::BackgroundWorker"
|
||||
private const val SHARED_PREF_MIN_DELAY_KEY = "BackgroundWorker::minDelaySeconds"
|
||||
private const val SHARED_PREF_REQUIRE_CHARGING_KEY = "BackgroundWorker::requireCharging"
|
||||
private const val SHARED_PREF_LOCK_KEY = "BackgroundWorker::isLocked"
|
||||
private const val SHARED_PREF_NOTIF_TITLE_KEY = "BackgroundWorker::notificationTitle"
|
||||
private const val SHARED_PREF_NOTIF_MSG_KEY = "BackgroundWorker::notificationMessage"
|
||||
|
||||
private const val DEFAULT_MIN_DELAY_SECONDS = 30L
|
||||
private const val DEFAULT_REQUIRE_CHARGING = false
|
||||
private const val DEFAULT_NOTIF_TITLE = "Uploading media"
|
||||
private const val DEFAULT_NOTIF_MSG = "Checking for new assets…"
|
||||
}
|
||||
|
||||
private val sp: SharedPreferences by lazy {
|
||||
ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
fun updateSettings(settings: BackgroundWorkerSettings) {
|
||||
sp.edit {
|
||||
putLong(SHARED_PREF_MIN_DELAY_KEY, settings.minimumDelaySeconds)
|
||||
putBoolean(SHARED_PREF_REQUIRE_CHARGING_KEY, settings.requiresCharging)
|
||||
}
|
||||
}
|
||||
|
||||
fun getSettings(): BackgroundWorkerSettings {
|
||||
val delaySeconds = sp.getLong(SHARED_PREF_MIN_DELAY_KEY, DEFAULT_MIN_DELAY_SECONDS)
|
||||
|
||||
return BackgroundWorkerSettings(
|
||||
minimumDelaySeconds = if (delaySeconds >= 1000) delaySeconds / 1000 else delaySeconds,
|
||||
requiresCharging = sp.getBoolean(
|
||||
SHARED_PREF_REQUIRE_CHARGING_KEY,
|
||||
DEFAULT_REQUIRE_CHARGING
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun updateNotificationConfig(title: String, message: String) {
|
||||
sp.edit {
|
||||
putString(SHARED_PREF_NOTIF_TITLE_KEY, title)
|
||||
putString(SHARED_PREF_NOTIF_MSG_KEY, message)
|
||||
}
|
||||
}
|
||||
|
||||
fun getNotificationConfig(): Pair<String, String> {
|
||||
val title =
|
||||
sp.getString(SHARED_PREF_NOTIF_TITLE_KEY, DEFAULT_NOTIF_TITLE) ?: DEFAULT_NOTIF_TITLE
|
||||
val message = sp.getString(SHARED_PREF_NOTIF_MSG_KEY, DEFAULT_NOTIF_MSG) ?: DEFAULT_NOTIF_MSG
|
||||
return Pair(title, message)
|
||||
}
|
||||
|
||||
fun setLocked(paused: Boolean) {
|
||||
sp.edit {
|
||||
putBoolean(SHARED_PREF_LOCK_KEY, paused)
|
||||
}
|
||||
}
|
||||
|
||||
fun isLocked(): Boolean {
|
||||
return sp.getBoolean(SHARED_PREF_LOCK_KEY, true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package app.alextran.immich.background
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
|
||||
class MediaObserver(context: Context, params: WorkerParameters) : Worker(context, params) {
|
||||
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)
|
||||
|
||||
// Enqueue backup worker only if there are new media changes
|
||||
if (triggeredContentUris.isNotEmpty()) {
|
||||
BackgroundWorkerApiImpl.enqueueBackgroundWorker(ctx)
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||
|
||||
package app.alextran.immich.connectivity
|
||||
|
||||
import android.util.Log
|
||||
import io.flutter.plugin.common.BasicMessageChannel
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MessageCodec
|
||||
import io.flutter.plugin.common.StandardMethodCodec
|
||||
import io.flutter.plugin.common.StandardMessageCodec
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
private object ConnectivityPigeonUtils {
|
||||
|
||||
fun wrapResult(result: Any?): List<Any?> {
|
||||
return listOf(result)
|
||||
}
|
||||
|
||||
fun wrapError(exception: Throwable): List<Any?> {
|
||||
return if (exception is FlutterError) {
|
||||
listOf(
|
||||
exception.code,
|
||||
exception.message,
|
||||
exception.details
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
exception.javaClass.simpleName,
|
||||
exception.toString(),
|
||||
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for passing custom error details to Flutter via a thrown PlatformException.
|
||||
* @property code The error code.
|
||||
* @property message The error message.
|
||||
* @property details The error details. Must be a datatype supported by the api codec.
|
||||
*/
|
||||
class FlutterError (
|
||||
val code: String,
|
||||
override val message: String? = null,
|
||||
val details: Any? = null
|
||||
) : Throwable()
|
||||
|
||||
enum class NetworkCapability(val raw: Int) {
|
||||
CELLULAR(0),
|
||||
WIFI(1),
|
||||
VPN(2),
|
||||
UNMETERED(3);
|
||||
|
||||
companion object {
|
||||
fun ofRaw(raw: Int): NetworkCapability? {
|
||||
return values().firstOrNull { it.raw == raw }
|
||||
}
|
||||
}
|
||||
}
|
||||
private open class ConnectivityPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return when (type) {
|
||||
129.toByte() -> {
|
||||
return (readValue(buffer) as Long?)?.let {
|
||||
NetworkCapability.ofRaw(it.toInt())
|
||||
}
|
||||
}
|
||||
else -> super.readValueOfType(type, buffer)
|
||||
}
|
||||
}
|
||||
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||
when (value) {
|
||||
is NetworkCapability -> {
|
||||
stream.write(129)
|
||||
writeValue(stream, value.raw)
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface ConnectivityApi {
|
||||
fun getCapabilities(): List<NetworkCapability>
|
||||
|
||||
companion object {
|
||||
/** The codec used by ConnectivityApi. */
|
||||
val codec: MessageCodec<Any?> by lazy {
|
||||
ConnectivityPigeonCodec()
|
||||
}
|
||||
/** Sets up an instance of `ConnectivityApi` to handle messages through the `binaryMessenger`. */
|
||||
@JvmOverloads
|
||||
fun setUp(binaryMessenger: BinaryMessenger, api: ConnectivityApi?, messageChannelSuffix: String = "") {
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val taskQueue = binaryMessenger.makeBackgroundTaskQueue()
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ConnectivityApi.getCapabilities$separatedMessageChannelSuffix", codec, taskQueue)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
listOf(api.getCapabilities())
|
||||
} catch (exception: Throwable) {
|
||||
ConnectivityPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package app.alextran.immich.connectivity
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.wifi.WifiManager
|
||||
|
||||
class ConnectivityApiImpl(context: Context) : ConnectivityApi {
|
||||
private val connectivityManager =
|
||||
context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
private val wifiManager =
|
||||
context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
||||
|
||||
override fun getCapabilities(): List<NetworkCapability> {
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
|
||||
?: return emptyList()
|
||||
|
||||
val hasWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI_AWARE)
|
||||
val hasCellular = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
val hasVpn = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
|
||||
val isUnmetered = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||
|
||||
return buildList {
|
||||
if (hasWifi) add(NetworkCapability.WIFI)
|
||||
if (hasCellular) add(NetworkCapability.CELLULAR)
|
||||
if (hasVpn) {
|
||||
add(NetworkCapability.VPN)
|
||||
if (!hasWifi && !hasCellular) {
|
||||
if (wifiManager.isWifiEnabled) add(NetworkCapability.WIFI)
|
||||
// If VPN is active, but neither WIFI nor CELLULAR is reported as active,
|
||||
// assume CELLULAR if WIFI is not enabled
|
||||
else add(NetworkCapability.CELLULAR)
|
||||
}
|
||||
}
|
||||
if (isUnmetered) add(NetworkCapability.UNMETERED)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package app.alextran.immich.images;
|
||||
|
||||
// Copyright (c) 2023 Evan Wallace
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
// modified to use native allocations
|
||||
public final class ThumbHash {
|
||||
/**
|
||||
* Decodes a ThumbHash to an RGBA image. RGB is not be premultiplied by A.
|
||||
*
|
||||
* @param hash The bytes of the ThumbHash.
|
||||
* @return The width, height, and pixels of the rendered placeholder image.
|
||||
*/
|
||||
public static Image thumbHashToRGBA(byte[] hash) {
|
||||
// Read the constants
|
||||
int header24 = (hash[0] & 255) | ((hash[1] & 255) << 8) | ((hash[2] & 255) << 16);
|
||||
int header16 = (hash[3] & 255) | ((hash[4] & 255) << 8);
|
||||
float l_dc = (float) (header24 & 63) / 63.0f;
|
||||
float p_dc = (float) ((header24 >> 6) & 63) / 31.5f - 1.0f;
|
||||
float q_dc = (float) ((header24 >> 12) & 63) / 31.5f - 1.0f;
|
||||
float l_scale = (float) ((header24 >> 18) & 31) / 31.0f;
|
||||
boolean hasAlpha = (header24 >> 23) != 0;
|
||||
float p_scale = (float) ((header16 >> 3) & 63) / 63.0f;
|
||||
float q_scale = (float) ((header16 >> 9) & 63) / 63.0f;
|
||||
boolean isLandscape = (header16 >> 15) != 0;
|
||||
int lx = Math.max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7);
|
||||
int ly = Math.max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7);
|
||||
float a_dc = hasAlpha ? (float) (hash[5] & 15) / 15.0f : 1.0f;
|
||||
float a_scale = (float) ((hash[5] >> 4) & 15) / 15.0f;
|
||||
|
||||
// Read the varying factors (boost saturation by 1.25x to compensate for quantization)
|
||||
int ac_start = hasAlpha ? 6 : 5;
|
||||
int ac_index = 0;
|
||||
Channel l_channel = new Channel(lx, ly);
|
||||
Channel p_channel = new Channel(3, 3);
|
||||
Channel q_channel = new Channel(3, 3);
|
||||
Channel a_channel = null;
|
||||
ac_index = l_channel.decode(hash, ac_start, ac_index, l_scale);
|
||||
ac_index = p_channel.decode(hash, ac_start, ac_index, p_scale * 1.25f);
|
||||
ac_index = q_channel.decode(hash, ac_start, ac_index, q_scale * 1.25f);
|
||||
if (hasAlpha) {
|
||||
a_channel = new Channel(5, 5);
|
||||
a_channel.decode(hash, ac_start, ac_index, a_scale);
|
||||
}
|
||||
float[] l_ac = l_channel.ac;
|
||||
float[] p_ac = p_channel.ac;
|
||||
float[] q_ac = q_channel.ac;
|
||||
float[] a_ac = hasAlpha ? a_channel.ac : null;
|
||||
|
||||
// Decode using the DCT into RGB
|
||||
float ratio = thumbHashToApproximateAspectRatio(hash);
|
||||
int w = Math.round(ratio > 1.0f ? 32.0f : 32.0f * ratio);
|
||||
int h = Math.round(ratio > 1.0f ? 32.0f / ratio : 32.0f);
|
||||
int size = w * h * 4;
|
||||
long pointer = ThumbnailsImpl.allocateNative(size);
|
||||
ByteBuffer rgba = ThumbnailsImpl.wrapAsBuffer(pointer, size);
|
||||
int cx_stop = Math.max(lx, hasAlpha ? 5 : 3);
|
||||
int cy_stop = Math.max(ly, hasAlpha ? 5 : 3);
|
||||
float[] fx = new float[cx_stop];
|
||||
float[] fy = new float[cy_stop];
|
||||
for (int y = 0, i = 0; y < h; y++) {
|
||||
for (int x = 0; x < w; x++, i += 4) {
|
||||
float l = l_dc, p = p_dc, q = q_dc, a = a_dc;
|
||||
|
||||
// Precompute the coefficients
|
||||
for (int cx = 0; cx < cx_stop; cx++)
|
||||
fx[cx] = (float) Math.cos(Math.PI / w * (x + 0.5f) * cx);
|
||||
for (int cy = 0; cy < cy_stop; cy++)
|
||||
fy[cy] = (float) Math.cos(Math.PI / h * (y + 0.5f) * cy);
|
||||
|
||||
// Decode L
|
||||
for (int cy = 0, j = 0; cy < ly; cy++) {
|
||||
float fy2 = fy[cy] * 2.0f;
|
||||
for (int cx = cy > 0 ? 0 : 1; cx * ly < lx * (ly - cy); cx++, j++)
|
||||
l += l_ac[j] * fx[cx] * fy2;
|
||||
}
|
||||
|
||||
// Decode P and Q
|
||||
for (int cy = 0, j = 0; cy < 3; cy++) {
|
||||
float fy2 = fy[cy] * 2.0f;
|
||||
for (int cx = cy > 0 ? 0 : 1; cx < 3 - cy; cx++, j++) {
|
||||
float f = fx[cx] * fy2;
|
||||
p += p_ac[j] * f;
|
||||
q += q_ac[j] * f;
|
||||
}
|
||||
}
|
||||
|
||||
// Decode A
|
||||
if (hasAlpha)
|
||||
for (int cy = 0, j = 0; cy < 5; cy++) {
|
||||
float fy2 = fy[cy] * 2.0f;
|
||||
for (int cx = cy > 0 ? 0 : 1; cx < 5 - cy; cx++, j++)
|
||||
a += a_ac[j] * fx[cx] * fy2;
|
||||
}
|
||||
|
||||
// Convert to RGB
|
||||
float b = l - 2.0f / 3.0f * p;
|
||||
float r = (3.0f * l - b + q) / 2.0f;
|
||||
float g = r - q;
|
||||
rgba.put(i, (byte) Math.max(0, Math.round(255.0f * Math.min(1, r))));
|
||||
rgba.put(i + 1, (byte) Math.max(0, Math.round(255.0f * Math.min(1, g))));
|
||||
rgba.put(i + 2, (byte) Math.max(0, Math.round(255.0f * Math.min(1, b))));
|
||||
rgba.put(i + 3, (byte) Math.max(0, Math.round(255.0f * Math.min(1, a))));
|
||||
}
|
||||
}
|
||||
return new Image(w, h, pointer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the approximate aspect ratio of the original image.
|
||||
*
|
||||
* @param hash The bytes of the ThumbHash.
|
||||
* @return The approximate aspect ratio (i.e. width / height).
|
||||
*/
|
||||
public static float thumbHashToApproximateAspectRatio(byte[] hash) {
|
||||
byte header = hash[3];
|
||||
boolean hasAlpha = (hash[2] & 0x80) != 0;
|
||||
boolean isLandscape = (hash[4] & 0x80) != 0;
|
||||
int lx = isLandscape ? hasAlpha ? 5 : 7 : header & 7;
|
||||
int ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7;
|
||||
return (float) lx / (float) ly;
|
||||
}
|
||||
|
||||
public static final class Image {
|
||||
public int width;
|
||||
public int height;
|
||||
public long pointer;
|
||||
|
||||
public Image(int width, int height, long pointer) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.pointer = pointer;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Channel {
|
||||
int nx;
|
||||
int ny;
|
||||
float[] ac;
|
||||
|
||||
Channel(int nx, int ny) {
|
||||
this.nx = nx;
|
||||
this.ny = ny;
|
||||
int n = 0;
|
||||
for (int cy = 0; cy < ny; cy++)
|
||||
for (int cx = cy > 0 ? 0 : 1; cx * ny < nx * (ny - cy); cx++)
|
||||
n++;
|
||||
ac = new float[n];
|
||||
}
|
||||
|
||||
int decode(byte[] hash, int start, int index, float scale) {
|
||||
for (int i = 0; i < ac.length; i++) {
|
||||
int data = hash[start + (index >> 1)] >> ((index & 1) << 2);
|
||||
ac[i] = ((float) (data & 15) / 7.5f - 1.0f) * scale;
|
||||
index++;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||
|
||||
package app.alextran.immich.images
|
||||
|
||||
import android.util.Log
|
||||
import io.flutter.plugin.common.BasicMessageChannel
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MessageCodec
|
||||
import io.flutter.plugin.common.StandardMethodCodec
|
||||
import io.flutter.plugin.common.StandardMessageCodec
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
private object ThumbnailsPigeonUtils {
|
||||
|
||||
fun wrapResult(result: Any?): List<Any?> {
|
||||
return listOf(result)
|
||||
}
|
||||
|
||||
fun wrapError(exception: Throwable): List<Any?> {
|
||||
return if (exception is FlutterError) {
|
||||
listOf(
|
||||
exception.code,
|
||||
exception.message,
|
||||
exception.details
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
exception.javaClass.simpleName,
|
||||
exception.toString(),
|
||||
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for passing custom error details to Flutter via a thrown PlatformException.
|
||||
* @property code The error code.
|
||||
* @property message The error message.
|
||||
* @property details The error details. Must be a datatype supported by the api codec.
|
||||
*/
|
||||
class FlutterError (
|
||||
val code: String,
|
||||
override val message: String? = null,
|
||||
val details: Any? = null
|
||||
) : Throwable()
|
||||
private open class ThumbnailsPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return super.readValueOfType(type, buffer)
|
||||
}
|
||||
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||
super.writeValue(stream, value)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface ThumbnailApi {
|
||||
fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, isVideo: Boolean, callback: (Result<Map<String, Long>>) -> Unit)
|
||||
fun cancelImageRequest(requestId: Long)
|
||||
fun getThumbhash(thumbhash: String, callback: (Result<Map<String, Long>>) -> Unit)
|
||||
|
||||
companion object {
|
||||
/** The codec used by ThumbnailApi. */
|
||||
val codec: MessageCodec<Any?> by lazy {
|
||||
ThumbnailsPigeonCodec()
|
||||
}
|
||||
/** Sets up an instance of `ThumbnailApi` to handle messages through the `binaryMessenger`. */
|
||||
@JvmOverloads
|
||||
fun setUp(binaryMessenger: BinaryMessenger, api: ThumbnailApi?, messageChannelSuffix: String = "") {
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ThumbnailApi.requestImage$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val assetIdArg = args[0] as String
|
||||
val requestIdArg = args[1] as Long
|
||||
val widthArg = args[2] as Long
|
||||
val heightArg = args[3] as Long
|
||||
val isVideoArg = args[4] as Boolean
|
||||
api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg) { result: Result<Map<String, Long>> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(ThumbnailsPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(ThumbnailsPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ThumbnailApi.cancelImageRequest$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val requestIdArg = args[0] as Long
|
||||
val wrapped: List<Any?> = try {
|
||||
api.cancelImageRequest(requestIdArg)
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
ThumbnailsPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ThumbnailApi.getThumbhash$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val thumbhashArg = args[0] as String
|
||||
api.getThumbhash(thumbhashArg) { result: Result<Map<String, Long>> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(ThumbnailsPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(ThumbnailsPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package app.alextran.immich.images
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.graphics.*
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import android.os.OperationCanceledException
|
||||
import android.provider.MediaStore.Images
|
||||
import android.provider.MediaStore.Video
|
||||
import android.util.Size
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.math.*
|
||||
import java.util.concurrent.Executors
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.load.DecodeFormat
|
||||
import com.bumptech.glide.request.target.Target.SIZE_ORIGINAL
|
||||
import java.util.Base64
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Future
|
||||
|
||||
data class Request(
|
||||
val taskFuture: Future<*>,
|
||||
val cancellationSignal: CancellationSignal,
|
||||
val callback: (Result<Map<String, Long>>) -> Unit
|
||||
)
|
||||
|
||||
class ThumbnailsImpl(context: Context) : ThumbnailApi {
|
||||
private val ctx: Context = context.applicationContext
|
||||
private val resolver: ContentResolver = ctx.contentResolver
|
||||
private val requestThread = Executors.newSingleThreadExecutor()
|
||||
private val threadPool =
|
||||
Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() / 2 + 1)
|
||||
private val requestMap = ConcurrentHashMap<Long, Request>()
|
||||
|
||||
companion object {
|
||||
val CANCELLED = Result.success<Map<String, Long>>(mapOf())
|
||||
val OPTIONS = BitmapFactory.Options().apply { inPreferredConfig = Bitmap.Config.ARGB_8888 }
|
||||
|
||||
init {
|
||||
System.loadLibrary("native_buffer")
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
external fun allocateNative(size: Int): Long
|
||||
|
||||
@JvmStatic
|
||||
external fun freeNative(pointer: Long)
|
||||
|
||||
@JvmStatic
|
||||
external fun wrapAsBuffer(address: Long, capacity: Int): ByteBuffer
|
||||
}
|
||||
|
||||
override fun getThumbhash(thumbhash: String, callback: (Result<Map<String, Long>>) -> Unit) {
|
||||
threadPool.execute {
|
||||
try {
|
||||
val bytes = Base64.getDecoder().decode(thumbhash)
|
||||
val image = ThumbHash.thumbHashToRGBA(bytes)
|
||||
val res = mapOf(
|
||||
"pointer" to image.pointer,
|
||||
"width" to image.width.toLong(),
|
||||
"height" to image.height.toLong()
|
||||
)
|
||||
callback(Result.success(res))
|
||||
} catch (e: Exception) {
|
||||
callback(Result.failure(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun requestImage(
|
||||
assetId: String,
|
||||
requestId: Long,
|
||||
width: Long,
|
||||
height: Long,
|
||||
isVideo: Boolean,
|
||||
callback: (Result<Map<String, Long>>) -> Unit
|
||||
) {
|
||||
val signal = CancellationSignal()
|
||||
val task = threadPool.submit {
|
||||
try {
|
||||
getThumbnailBufferInternal(assetId, width, height, isVideo, callback, signal)
|
||||
} catch (e: Exception) {
|
||||
when (e) {
|
||||
is OperationCanceledException -> callback(CANCELLED)
|
||||
is CancellationException -> callback(CANCELLED)
|
||||
else -> callback(Result.failure(e))
|
||||
}
|
||||
} finally {
|
||||
requestMap.remove(requestId)
|
||||
}
|
||||
}
|
||||
val request = Request(task, signal, callback)
|
||||
requestMap[requestId] = request
|
||||
}
|
||||
|
||||
override fun cancelImageRequest(requestId: Long) {
|
||||
val request = requestMap.remove(requestId) ?: return
|
||||
request.taskFuture.cancel(false)
|
||||
request.cancellationSignal.cancel()
|
||||
if (request.taskFuture.isCancelled) {
|
||||
requestThread.execute {
|
||||
try {
|
||||
request.callback(CANCELLED)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getThumbnailBufferInternal(
|
||||
assetId: String,
|
||||
width: Long,
|
||||
height: Long,
|
||||
isVideo: Boolean,
|
||||
callback: (Result<Map<String, Long>>) -> Unit,
|
||||
signal: CancellationSignal
|
||||
) {
|
||||
signal.throwIfCanceled()
|
||||
val size = Size(width.toInt(), height.toInt())
|
||||
val id = assetId.toLong()
|
||||
|
||||
signal.throwIfCanceled()
|
||||
val bitmap = if (isVideo) {
|
||||
decodeVideoThumbnail(id, size, signal)
|
||||
} else {
|
||||
decodeImage(id, size, signal)
|
||||
}
|
||||
|
||||
processBitmap(bitmap, callback, signal)
|
||||
}
|
||||
|
||||
private fun processBitmap(
|
||||
bitmap: Bitmap, callback: (Result<Map<String, Long>>) -> Unit, signal: CancellationSignal
|
||||
) {
|
||||
signal.throwIfCanceled()
|
||||
val actualWidth = bitmap.width
|
||||
val actualHeight = bitmap.height
|
||||
|
||||
val size = actualWidth * actualHeight * 4
|
||||
val pointer = allocateNative(size)
|
||||
|
||||
try {
|
||||
signal.throwIfCanceled()
|
||||
val buffer = wrapAsBuffer(pointer, size)
|
||||
bitmap.copyPixelsToBuffer(buffer)
|
||||
bitmap.recycle()
|
||||
signal.throwIfCanceled()
|
||||
val res = mapOf(
|
||||
"pointer" to pointer, "width" to actualWidth.toLong(), "height" to actualHeight.toLong()
|
||||
)
|
||||
callback(Result.success(res))
|
||||
} catch (e: Exception) {
|
||||
freeNative(pointer)
|
||||
callback(if (e is OperationCanceledException) CANCELLED else Result.failure(e))
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeImage(id: Long, size: Size, signal: CancellationSignal): Bitmap {
|
||||
signal.throwIfCanceled()
|
||||
val uri = ContentUris.withAppendedId(Images.Media.EXTERNAL_CONTENT_URI, id)
|
||||
if (size.width <= 0 || size.height <= 0 || size.width > 768 || size.height > 768) {
|
||||
return decodeSource(uri, size, signal)
|
||||
}
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
resolver.loadThumbnail(uri, size, signal)
|
||||
} else {
|
||||
signal.setOnCancelListener { Images.Thumbnails.cancelThumbnailRequest(resolver, id) }
|
||||
Images.Thumbnails.getThumbnail(resolver, id, Images.Thumbnails.MINI_KIND, OPTIONS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeVideoThumbnail(id: Long, target: Size, signal: CancellationSignal): Bitmap {
|
||||
signal.throwIfCanceled()
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val uri = ContentUris.withAppendedId(Video.Media.EXTERNAL_CONTENT_URI, id)
|
||||
// ensure a valid resolution as the thumbnail is used for videos even when no scaling is needed
|
||||
val size = if (target.width > 0 && target.height > 0) target else Size(768, 768)
|
||||
resolver.loadThumbnail(uri, size, signal)
|
||||
} else {
|
||||
signal.setOnCancelListener { Video.Thumbnails.cancelThumbnailRequest(resolver, id) }
|
||||
Video.Thumbnails.getThumbnail(resolver, id, Video.Thumbnails.MINI_KIND, OPTIONS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeSource(uri: Uri, target: Size, signal: CancellationSignal): Bitmap {
|
||||
signal.throwIfCanceled()
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val source = ImageDecoder.createSource(resolver, uri)
|
||||
signal.throwIfCanceled()
|
||||
ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
|
||||
if (target.width > 0 && target.height > 0) {
|
||||
val sample = max(1, min(info.size.width / target.width, info.size.height / target.height))
|
||||
decoder.setTargetSampleSize(sample)
|
||||
}
|
||||
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
|
||||
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
|
||||
}
|
||||
} else {
|
||||
val ref =
|
||||
Glide.with(ctx).asBitmap().priority(Priority.IMMEDIATE).load(uri).disallowHardwareConfig()
|
||||
.format(DecodeFormat.PREFER_ARGB_8888).submit(
|
||||
if (target.width > 0) target.width else SIZE_ORIGINAL,
|
||||
if (target.height > 0) target.height else SIZE_ORIGINAL,
|
||||
)
|
||||
signal.setOnCancelListener { Glide.with(ctx).clear(ref) }
|
||||
ref.get()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Autogenerated from Pigeon (v25.3.2), do not edit directly.
|
||||
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||
|
||||
@@ -88,7 +88,8 @@ data class PlatformAsset (
|
||||
val width: Long? = null,
|
||||
val height: Long? = null,
|
||||
val durationInSeconds: Long,
|
||||
val orientation: Long
|
||||
val orientation: Long,
|
||||
val isFavorite: Boolean
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
@@ -102,7 +103,8 @@ data class PlatformAsset (
|
||||
val height = pigeonVar_list[6] as Long?
|
||||
val durationInSeconds = pigeonVar_list[7] as Long
|
||||
val orientation = pigeonVar_list[8] as Long
|
||||
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation)
|
||||
val isFavorite = pigeonVar_list[9] as Boolean
|
||||
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
@@ -116,6 +118,7 @@ data class PlatformAsset (
|
||||
height,
|
||||
durationInSeconds,
|
||||
orientation,
|
||||
isFavorite,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
@@ -206,6 +209,40 @@ data class SyncDelta (
|
||||
|
||||
override fun hashCode(): Int = toList().hashCode()
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class HashResult (
|
||||
val assetId: String,
|
||||
val error: String? = null,
|
||||
val hash: String? = null
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): HashResult {
|
||||
val assetId = pigeonVar_list[0] as String
|
||||
val error = pigeonVar_list[1] as String?
|
||||
val hash = pigeonVar_list[2] as String?
|
||||
return HashResult(assetId, error, hash)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
assetId,
|
||||
error,
|
||||
hash,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other !is HashResult) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
return MessagesPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||
|
||||
override fun hashCode(): Int = toList().hashCode()
|
||||
}
|
||||
private open class MessagesPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return when (type) {
|
||||
@@ -224,6 +261,11 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
||||
SyncDelta.fromList(it)
|
||||
}
|
||||
}
|
||||
132.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
HashResult.fromList(it)
|
||||
}
|
||||
}
|
||||
else -> super.readValueOfType(type, buffer)
|
||||
}
|
||||
}
|
||||
@@ -241,11 +283,16 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
||||
stream.write(131)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is HashResult -> {
|
||||
stream.write(132)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface NativeSyncApi {
|
||||
fun shouldFullSync(): Boolean
|
||||
@@ -256,7 +303,8 @@ interface NativeSyncApi {
|
||||
fun getAlbums(): List<PlatformAlbum>
|
||||
fun getAssetsCountSince(albumId: String, timestamp: Long): Long
|
||||
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset>
|
||||
fun hashPaths(paths: List<String>): List<ByteArray?>
|
||||
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
|
||||
fun cancelHashing()
|
||||
|
||||
companion object {
|
||||
/** The codec used by NativeSyncApi. */
|
||||
@@ -399,13 +447,33 @@ interface NativeSyncApi {
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths$separatedMessageChannelSuffix", codec, taskQueue)
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets$separatedMessageChannelSuffix", codec, taskQueue)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val pathsArg = args[0] as List<String>
|
||||
val assetIdsArg = args[0] as List<String>
|
||||
val allowNetworkAccessArg = args[1] as Boolean
|
||||
api.hashAssets(assetIdsArg, allowNetworkAccessArg) { result: Result<List<HashResult>> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
listOf(api.hashPaths(pathsArg))
|
||||
api.cancelHashing()
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
MessagesPigeonUtils.wrapError(exception)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
package app.alextran.immich.sync
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import android.util.Base64
|
||||
import androidx.core.database.getStringOrNull
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.security.MessageDigest
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
sealed class AssetResult {
|
||||
data class ValidAsset(val asset: PlatformAsset, val albumId: String) : AssetResult()
|
||||
@@ -19,8 +30,12 @@ sealed class AssetResult {
|
||||
open class NativeSyncApiImplBase(context: Context) {
|
||||
private val ctx: Context = context.applicationContext
|
||||
|
||||
private var hashTask: Job? = null
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NativeSyncApiImplBase"
|
||||
private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
|
||||
private val hashSemaphore = Semaphore(MAX_CONCURRENT_HASH_OPERATIONS)
|
||||
private const val HASHING_CANCELLED_CODE = "HASH_CANCELLED"
|
||||
|
||||
const val MEDIA_SELECTION =
|
||||
"(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)"
|
||||
@@ -29,20 +44,24 @@ open class NativeSyncApiImplBase(context: Context) {
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString()
|
||||
)
|
||||
const val BUCKET_SELECTION = "(${MediaStore.Files.FileColumns.BUCKET_ID} = ?)"
|
||||
val ASSET_PROJECTION = arrayOf(
|
||||
MediaStore.MediaColumns._ID,
|
||||
MediaStore.MediaColumns.DATA,
|
||||
MediaStore.MediaColumns.DISPLAY_NAME,
|
||||
MediaStore.MediaColumns.DATE_TAKEN,
|
||||
MediaStore.MediaColumns.DATE_ADDED,
|
||||
MediaStore.MediaColumns.DATE_MODIFIED,
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE,
|
||||
MediaStore.MediaColumns.BUCKET_ID,
|
||||
MediaStore.MediaColumns.WIDTH,
|
||||
MediaStore.MediaColumns.HEIGHT,
|
||||
MediaStore.MediaColumns.DURATION,
|
||||
MediaStore.MediaColumns.ORIENTATION,
|
||||
)
|
||||
val ASSET_PROJECTION = buildList {
|
||||
add(MediaStore.MediaColumns._ID)
|
||||
add(MediaStore.MediaColumns.DATA)
|
||||
add(MediaStore.MediaColumns.DISPLAY_NAME)
|
||||
add(MediaStore.MediaColumns.DATE_TAKEN)
|
||||
add(MediaStore.MediaColumns.DATE_ADDED)
|
||||
add(MediaStore.MediaColumns.DATE_MODIFIED)
|
||||
add(MediaStore.Files.FileColumns.MEDIA_TYPE)
|
||||
add(MediaStore.MediaColumns.BUCKET_ID)
|
||||
add(MediaStore.MediaColumns.WIDTH)
|
||||
add(MediaStore.MediaColumns.HEIGHT)
|
||||
add(MediaStore.MediaColumns.DURATION)
|
||||
add(MediaStore.MediaColumns.ORIENTATION)
|
||||
// IS_FAVORITE is only available on Android 11 and above
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
|
||||
add(MediaStore.MediaColumns.IS_FAVORITE)
|
||||
}
|
||||
}.toTypedArray()
|
||||
|
||||
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
|
||||
}
|
||||
@@ -77,6 +96,7 @@ open class NativeSyncApiImplBase(context: Context) {
|
||||
val durationColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DURATION)
|
||||
val orientationColumn =
|
||||
c.getColumnIndexOrThrow(MediaStore.MediaColumns.ORIENTATION)
|
||||
val favoriteColumn = c.getColumnIndex(MediaStore.MediaColumns.IS_FAVORITE)
|
||||
|
||||
while (c.moveToNext()) {
|
||||
val id = c.getLong(idColumn).toString()
|
||||
@@ -105,6 +125,7 @@ open class NativeSyncApiImplBase(context: Context) {
|
||||
else c.getLong(durationColumn) / 1000
|
||||
val bucketId = c.getString(bucketIdColumn)
|
||||
val orientation = c.getInt(orientationColumn)
|
||||
val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0
|
||||
|
||||
val asset = PlatformAsset(
|
||||
id,
|
||||
@@ -116,6 +137,7 @@ open class NativeSyncApiImplBase(context: Context) {
|
||||
height,
|
||||
duration,
|
||||
orientation.toLong(),
|
||||
isFavorite,
|
||||
)
|
||||
yield(AssetResult.ValidAsset(asset, bucketId))
|
||||
}
|
||||
@@ -208,23 +230,74 @@ open class NativeSyncApiImplBase(context: Context) {
|
||||
.toList()
|
||||
}
|
||||
|
||||
fun hashPaths(paths: List<String>): List<ByteArray?> {
|
||||
val buffer = ByteArray(HASH_BUFFER_SIZE)
|
||||
val digest = MessageDigest.getInstance("SHA-1")
|
||||
fun hashAssets(
|
||||
assetIds: List<String>,
|
||||
// allowNetworkAccess is only used on the iOS implementation
|
||||
@Suppress("UNUSED_PARAMETER") allowNetworkAccess: Boolean,
|
||||
callback: (Result<List<HashResult>>) -> Unit
|
||||
) {
|
||||
if (assetIds.isEmpty()) {
|
||||
callback(Result.success(emptyList()))
|
||||
return
|
||||
}
|
||||
|
||||
return paths.map { path ->
|
||||
hashTask?.cancel()
|
||||
hashTask = CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
FileInputStream(path).use { file ->
|
||||
var bytesRead: Int
|
||||
while (file.read(buffer).also { bytesRead = it } > 0) {
|
||||
digest.update(buffer, 0, bytesRead)
|
||||
val results = assetIds.map { assetId ->
|
||||
async {
|
||||
hashSemaphore.withPermit {
|
||||
ensureActive()
|
||||
hashAsset(assetId)
|
||||
}
|
||||
}
|
||||
}
|
||||
digest.digest()
|
||||
}.awaitAll()
|
||||
|
||||
callback(Result.success(results))
|
||||
} catch (e: CancellationException) {
|
||||
callback(
|
||||
Result.failure(
|
||||
FlutterError(
|
||||
HASHING_CANCELLED_CODE,
|
||||
"Hashing operation was cancelled",
|
||||
null
|
||||
)
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to hash file $path: $e")
|
||||
null
|
||||
callback(Result.failure(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun hashAsset(assetId: String): HashResult {
|
||||
return try {
|
||||
val assetUri = ContentUris.withAppendedId(
|
||||
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
|
||||
assetId.toLong()
|
||||
)
|
||||
|
||||
val digest = MessageDigest.getInstance("SHA-1")
|
||||
ctx.contentResolver.openInputStream(assetUri)?.use { inputStream ->
|
||||
var bytesRead: Int
|
||||
val buffer = ByteArray(HASH_BUFFER_SIZE)
|
||||
while (inputStream.read(buffer).also { bytesRead = it } > 0) {
|
||||
coroutineContext.ensureActive()
|
||||
digest.update(buffer, 0, bytesRead)
|
||||
}
|
||||
} ?: return HashResult(assetId, "Cannot open input stream for asset", null)
|
||||
|
||||
val hashString = Base64.encodeToString(digest.digest(), Base64.NO_WRAP)
|
||||
HashResult(assetId, null, hashString)
|
||||
} catch (e: SecurityException) {
|
||||
HashResult(assetId, "Permission denied accessing asset: ${e.message}", null)
|
||||
} catch (e: Exception) {
|
||||
HashResult(assetId, "Failed to hash asset: ${e.message}", null)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelHashing() {
|
||||
hashTask?.cancel()
|
||||
hashTask = null
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ class ImageDownloadWorker(
|
||||
.build()
|
||||
|
||||
manager.enqueueUniqueWork(
|
||||
"$uniqueWorkName-$appWidgetId",
|
||||
"$uniqueWorkName-$appWidgetId-singleShot",
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
workRequest
|
||||
)
|
||||
|
||||
@@ -24,14 +24,23 @@ class ImmichAPI(cfg: ServerConfig) {
|
||||
|
||||
val serverURL = prefs.getString("widget_server_url", "") ?: ""
|
||||
val sessionKey = prefs.getString("widget_auth_token", "") ?: ""
|
||||
val customHeadersJSON = prefs.getString("widget_custom_headers", "") ?: ""
|
||||
|
||||
if (serverURL.isBlank() || sessionKey.isBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
var customHeaders: Map<String, String> = HashMap<String, String>()
|
||||
|
||||
if (customHeadersJSON.isNotBlank()) {
|
||||
val stringMapType = object : TypeToken<Map<String, String>>() {}.type
|
||||
customHeaders = Gson().fromJson(customHeadersJSON, stringMapType)
|
||||
}
|
||||
|
||||
return ServerConfig(
|
||||
serverURL,
|
||||
sessionKey
|
||||
sessionKey,
|
||||
customHeaders
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -50,11 +59,19 @@ class ImmichAPI(cfg: ServerConfig) {
|
||||
return URL(urlString.toString())
|
||||
}
|
||||
|
||||
private fun HttpURLConnection.applyCustomHeaders() {
|
||||
serverConfig.customHeaders.forEach { (key, value) ->
|
||||
setRequestProperty(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchSearchResults(filters: SearchFilters): List<Asset> = withContext(Dispatchers.IO) {
|
||||
val url = buildRequestURL("/search/random")
|
||||
val connection = (url.openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "POST"
|
||||
setRequestProperty("Content-Type", "application/json")
|
||||
applyCustomHeaders()
|
||||
|
||||
doOutput = true
|
||||
}
|
||||
|
||||
@@ -75,6 +92,7 @@ class ImmichAPI(cfg: ServerConfig) {
|
||||
val url = buildRequestURL("/memories", listOf("for" to iso8601))
|
||||
val connection = (url.openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "GET"
|
||||
applyCustomHeaders()
|
||||
}
|
||||
|
||||
val response = connection.inputStream.bufferedReader().readText()
|
||||
@@ -94,6 +112,7 @@ class ImmichAPI(cfg: ServerConfig) {
|
||||
val url = buildRequestURL("/albums")
|
||||
val connection = (url.openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "GET"
|
||||
applyCustomHeaders()
|
||||
}
|
||||
|
||||
val response = connection.inputStream.bufferedReader().readText()
|
||||
|
||||
@@ -28,19 +28,21 @@ class MemoryReceiver : GlanceAppWidgetReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val fromMainApp = intent.getBooleanExtra(HomeWidgetPlugin.TRIGGERED_FROM_HOME_WIDGET, false)
|
||||
val provider = ComponentName(context, MemoryReceiver::class.java)
|
||||
val glanceIds = AppWidgetManager.getInstance(context).getAppWidgetIds(provider)
|
||||
|
||||
// Launch coroutine to setup a single shot if the app requested the update
|
||||
if (fromMainApp) {
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
val provider = ComponentName(context, MemoryReceiver::class.java)
|
||||
val glanceIds = AppWidgetManager.getInstance(context).getAppWidgetIds(provider)
|
||||
|
||||
glanceIds.forEach { widgetID ->
|
||||
ImageDownloadWorker.singleShot(context, widgetID, WidgetType.MEMORIES)
|
||||
}
|
||||
glanceIds.forEach { widgetID ->
|
||||
ImageDownloadWorker.singleShot(context, widgetID, WidgetType.MEMORIES)
|
||||
}
|
||||
}
|
||||
|
||||
// make sure the periodic jobs are running
|
||||
glanceIds.forEach { widgetID ->
|
||||
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.MEMORIES)
|
||||
}
|
||||
|
||||
super.onReceive(context, intent)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,19 +28,21 @@ class RandomReceiver : GlanceAppWidgetReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val fromMainApp = intent.getBooleanExtra(HomeWidgetPlugin.TRIGGERED_FROM_HOME_WIDGET, false)
|
||||
val provider = ComponentName(context, RandomReceiver::class.java)
|
||||
val glanceIds = AppWidgetManager.getInstance(context).getAppWidgetIds(provider)
|
||||
|
||||
// Launch coroutine to setup a single shot if the app requested the update
|
||||
if (fromMainApp) {
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
val provider = ComponentName(context, RandomReceiver::class.java)
|
||||
val glanceIds = AppWidgetManager.getInstance(context).getAppWidgetIds(provider)
|
||||
|
||||
glanceIds.forEach { widgetID ->
|
||||
ImageDownloadWorker.singleShot(context, widgetID, WidgetType.RANDOM)
|
||||
}
|
||||
glanceIds.forEach { widgetID ->
|
||||
ImageDownloadWorker.singleShot(context, widgetID, WidgetType.RANDOM)
|
||||
}
|
||||
}
|
||||
|
||||
// make sure the periodic jobs are running
|
||||
glanceIds.forEach { widgetID ->
|
||||
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.RANDOM)
|
||||
}
|
||||
|
||||
super.onReceive(context, intent)
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,11 @@ data class WidgetEntry (
|
||||
val deeplink: String?
|
||||
)
|
||||
|
||||
data class ServerConfig(val serverEndpoint: String, val sessionKey: String)
|
||||
data class ServerConfig(
|
||||
val serverEndpoint: String,
|
||||
val sessionKey: String,
|
||||
val customHeaders: Map<String, String>
|
||||
)
|
||||
|
||||
// MARK: Widget State Keys
|
||||
val kImageUUID = stringPreferencesKey("uuid")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
allprojects {
|
||||
ext.kotlin_version = '2.0.20'
|
||||
ext.kotlin_version = '2.2.20'
|
||||
|
||||
repositories {
|
||||
google()
|
||||
@@ -16,8 +16,8 @@ subprojects {
|
||||
if (project.plugins.hasPlugin("com.android.application") ||
|
||||
project.plugins.hasPlugin("com.android.library")) {
|
||||
project.android {
|
||||
compileSdkVersion 35
|
||||
buildToolsVersion "35.0.0"
|
||||
compileSdkVersion 36
|
||||
buildToolsVersion "36.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 204,
|
||||
"android.injected.version.name" => "1.135.3",
|
||||
"android.injected.version.code" => 3021,
|
||||
"android.injected.version.name" => "2.0.1",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@@ -18,10 +18,10 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version '8.7.2' apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.0.20" apply false
|
||||
id "com.android.application" version '8.11.2' apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.2.20" apply false
|
||||
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.22' apply false
|
||||
id 'com.google.devtools.ksp' version '2.0.20-1.0.24' apply false
|
||||
id 'com.google.devtools.ksp' version '2.2.20-2.0.3' apply false
|
||||
}
|
||||
|
||||
include ":app"
|
||||
|
||||
@@ -19,6 +19,7 @@ targets:
|
||||
- lib/infrastructure/entities/*.dart
|
||||
- lib/infrastructure/entities/*.drift
|
||||
- lib/infrastructure/repositories/db.repository.dart
|
||||
- lib/infrastructure/repositories/logger_db.repository.dart
|
||||
drift_dev:modular:
|
||||
enabled: true
|
||||
options: *drift_options
|
||||
|
||||
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
@@ -39,16 +39,17 @@ class ImmichTestHelper {
|
||||
static Future<void> loadApp(WidgetTester tester) async {
|
||||
await EasyLocalization.ensureInitialized();
|
||||
// Clear all data from Isar (reuse existing instance if available)
|
||||
final db = await Bootstrap.initIsar();
|
||||
await Bootstrap.initDomain(db);
|
||||
final (isar, drift, logDb) = await Bootstrap.initDB();
|
||||
await Bootstrap.initDomain(isar, drift, logDb);
|
||||
await Store.clear();
|
||||
await db.writeTxn(() => db.clear());
|
||||
await isar.writeTxn(() => isar.clear());
|
||||
// Load main Widget
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
dbProvider.overrideWithValue(db),
|
||||
isarProvider.overrideWithValue(db),
|
||||
dbProvider.overrideWithValue(isar),
|
||||
isarProvider.overrideWithValue(isar),
|
||||
driftProvider.overrideWith(driftOverride(drift)),
|
||||
],
|
||||
child: const app.MainWidget(),
|
||||
),
|
||||
@@ -59,18 +60,11 @@ class ImmichTestHelper {
|
||||
}
|
||||
|
||||
@isTest
|
||||
void immichWidgetTest(
|
||||
String description,
|
||||
Future<void> Function(WidgetTester, ImmichTestHelper) test,
|
||||
) {
|
||||
testWidgets(
|
||||
description,
|
||||
(widgetTester) async {
|
||||
await ImmichTestHelper.loadApp(widgetTester);
|
||||
await test(widgetTester, ImmichTestHelper(widgetTester));
|
||||
},
|
||||
semanticsEnabled: false,
|
||||
);
|
||||
void immichWidgetTest(String description, Future<void> Function(WidgetTester, ImmichTestHelper) test) {
|
||||
testWidgets(description, (widgetTester) async {
|
||||
await ImmichTestHelper.loadApp(widgetTester);
|
||||
await test(widgetTester, ImmichTestHelper(widgetTester));
|
||||
}, semanticsEnabled: false);
|
||||
}
|
||||
|
||||
Future<void> pumpUntilFound(
|
||||
@@ -79,8 +73,7 @@ Future<void> pumpUntilFound(
|
||||
Duration timeout = const Duration(seconds: 120),
|
||||
}) async {
|
||||
bool found = false;
|
||||
final timer =
|
||||
Timer(timeout, () => throw TimeoutException("Pump until has timed out"));
|
||||
final timer = Timer(timeout, () => throw TimeoutException("Pump until has timed out"));
|
||||
while (found != true) {
|
||||
await tester.pump();
|
||||
found = tester.any(finder);
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
*.moved-aside
|
||||
*.pbxuser
|
||||
*.perspectivev3
|
||||
**/*sync/
|
||||
.sconsign.dblite
|
||||
.tags*
|
||||
**/.vagrant/
|
||||
|
||||
@@ -21,6 +21,6 @@
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>12.0</string>
|
||||
<string>13.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -64,7 +64,7 @@ PODS:
|
||||
- Flutter
|
||||
- integration_test (0.0.1):
|
||||
- Flutter
|
||||
- isar_flutter_libs (1.0.0):
|
||||
- isar_community_flutter_libs (1.0.0):
|
||||
- Flutter
|
||||
- local_auth_darwin (0.0.1):
|
||||
- Flutter
|
||||
@@ -149,7 +149,7 @@ DEPENDENCIES:
|
||||
- home_widget (from `.symlinks/plugins/home_widget/ios`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
|
||||
- isar_community_flutter_libs (from `.symlinks/plugins/isar_community_flutter_libs/ios`)
|
||||
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
|
||||
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
|
||||
- native_video_player (from `.symlinks/plugins/native_video_player/ios`)
|
||||
@@ -210,8 +210,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
integration_test:
|
||||
:path: ".symlinks/plugins/integration_test/ios"
|
||||
isar_flutter_libs:
|
||||
:path: ".symlinks/plugins/isar_flutter_libs/ios"
|
||||
isar_community_flutter_libs:
|
||||
:path: ".symlinks/plugins/isar_community_flutter_libs/ios"
|
||||
local_auth_darwin:
|
||||
:path: ".symlinks/plugins/local_auth_darwin/darwin"
|
||||
maplibre_gl:
|
||||
@@ -253,7 +253,7 @@ SPEC CHECKSUMS:
|
||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
|
||||
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
||||
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
||||
@@ -264,7 +264,7 @@ SPEC CHECKSUMS:
|
||||
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
|
||||
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
|
||||
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
|
||||
isar_flutter_libs: bc909e72c3d756c2759f14c8776c13b5b0556e26
|
||||
isar_community_flutter_libs: bede843185a61a05ff364a05c9b23209523f7e0d
|
||||
local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391
|
||||
MapLibre: 69e572367f4ef6287e18246cfafc39c80cdcabcd
|
||||
maplibre_gl: 3c924e44725147b03dda33430ad216005b40555f
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */; };
|
||||
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; };
|
||||
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */; };
|
||||
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */; };
|
||||
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */; };
|
||||
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */; };
|
||||
F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
F0B57D3A2DF764BD00DC5BCC /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F0B57D392DF764BD00DC5BCC /* WidgetKit.framework */; };
|
||||
@@ -24,6 +29,9 @@
|
||||
FAC6F89B2D287C890078CB2F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FAC6F8902D287C890078CB2F /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
FAC6F8B72D287F120078CB2F /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC6F8B52D287F120078CB2F /* ShareViewController.swift */; };
|
||||
FAC6F8B92D287F120078CB2F /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */; };
|
||||
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; };
|
||||
FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */; };
|
||||
FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -89,6 +97,11 @@
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
B1FBA9EE014DE20271B0FE77 /* Pods-ShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorkerApiImpl.swift; sourceTree = "<group>"; };
|
||||
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = "<group>"; };
|
||||
B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.g.swift; sourceTree = "<group>"; };
|
||||
B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityApiImpl.swift; sourceTree = "<group>"; };
|
||||
B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.g.swift; sourceTree = "<group>"; };
|
||||
E0E99CDC17B3EB7FA8BA2332 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
F0B57D392DF764BD00DC5BCC /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
||||
@@ -102,6 +115,9 @@
|
||||
FAC6F8B42D287F120078CB2F /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
|
||||
FAC6F8B52D287F120078CB2F /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
||||
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = "<group>"; };
|
||||
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = "<group>"; };
|
||||
FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbnails.g.swift; sourceTree = "<group>"; };
|
||||
FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailsImpl.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@@ -231,6 +247,8 @@
|
||||
97C146F01CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B25D37792E72CA15008B6CA7 /* Connectivity */,
|
||||
B21E34A62E5AF9760031FDB9 /* Background */,
|
||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
||||
FA9973382CF6DF4B000EF859 /* Runner.entitlements */,
|
||||
65DD438629917FAD0047FFA8 /* BackgroundSync */,
|
||||
@@ -243,10 +261,30 @@
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
FED3B1952E253E9B0030FD97 /* Images */,
|
||||
);
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B21E34A62E5AF9760031FDB9 /* Background */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */,
|
||||
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */,
|
||||
B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */,
|
||||
);
|
||||
path = Background;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B25D37792E72CA15008B6CA7 /* Connectivity */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */,
|
||||
B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */,
|
||||
);
|
||||
path = Connectivity;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FAC6F8B62D287F120078CB2F /* ShareExtension */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -258,6 +296,16 @@
|
||||
path = ShareExtension;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FED3B1952E253E9B0030FD97 /* Images */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */,
|
||||
FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */,
|
||||
FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */,
|
||||
);
|
||||
path = Images;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -523,7 +571,15 @@
|
||||
files = (
|
||||
65F32F31299BD2F800CE9261 /* BackgroundServicePlugin.swift in Sources */,
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */,
|
||||
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */,
|
||||
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
|
||||
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */,
|
||||
FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */,
|
||||
B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */,
|
||||
FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */,
|
||||
65F32F33299D349D00CE9261 /* BackgroundSyncWorker.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -649,7 +705,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 210;
|
||||
CURRENT_PROJECT_VERSION = 230;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -793,7 +849,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 210;
|
||||
CURRENT_PROJECT_VERSION = 230;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -823,7 +879,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 210;
|
||||
CURRENT_PROJECT_VERSION = 230;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -857,7 +913,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 210;
|
||||
CURRENT_PROJECT_VERSION = 230;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
@@ -900,7 +956,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 210;
|
||||
CURRENT_PROJECT_VERSION = 230;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
@@ -940,7 +996,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 210;
|
||||
CURRENT_PROJECT_VERSION = 230;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
@@ -979,7 +1035,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 210;
|
||||
CURRENT_PROJECT_VERSION = 230;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -1023,7 +1079,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 210;
|
||||
CURRENT_PROJECT_VERSION = 230;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -1064,7 +1120,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 210;
|
||||
CURRENT_PROJECT_VERSION = 230;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
|
||||
@@ -19,12 +19,12 @@ import UIKit
|
||||
}
|
||||
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
BackgroundServicePlugin.registerBackgroundProcessing()
|
||||
|
||||
BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!)
|
||||
|
||||
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
|
||||
NativeSyncApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: NativeSyncApiImpl())
|
||||
AppDelegate.registerPlugins(binaryMessenger: controller.binaryMessenger)
|
||||
BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!)
|
||||
|
||||
BackgroundServicePlugin.registerBackgroundProcessing()
|
||||
BackgroundWorkerApiImpl.registerBackgroundWorkers()
|
||||
|
||||
BackgroundServicePlugin.setPluginRegistrantCallback { registry in
|
||||
if !registry.hasPlugin("org.cocoapods.path-provider-foundation") {
|
||||
@@ -50,4 +50,10 @@ import UIKit
|
||||
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
public static func registerPlugins(binaryMessenger: FlutterBinaryMessenger) {
|
||||
NativeSyncApiSetup.setUp(binaryMessenger: binaryMessenger, api: NativeSyncApiImpl())
|
||||
ThumbnailApiSetup.setUp(binaryMessenger: binaryMessenger, api: ThumbnailApiImpl())
|
||||
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: BackgroundWorkerApiImpl())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
|
||||
import Foundation
|
||||
|
||||
#if os(iOS)
|
||||
import Flutter
|
||||
#elseif os(macOS)
|
||||
import FlutterMacOS
|
||||
#else
|
||||
#error("Unsupported platform.")
|
||||
#endif
|
||||
|
||||
private func wrapResult(_ result: Any?) -> [Any?] {
|
||||
return [result]
|
||||
}
|
||||
|
||||
private func wrapError(_ error: Any) -> [Any?] {
|
||||
if let pigeonError = error as? PigeonError {
|
||||
return [
|
||||
pigeonError.code,
|
||||
pigeonError.message,
|
||||
pigeonError.details,
|
||||
]
|
||||
}
|
||||
if let flutterError = error as? FlutterError {
|
||||
return [
|
||||
flutterError.code,
|
||||
flutterError.message,
|
||||
flutterError.details,
|
||||
]
|
||||
}
|
||||
return [
|
||||
"\(error)",
|
||||
"\(type(of: error))",
|
||||
"Stacktrace: \(Thread.callStackSymbols)",
|
||||
]
|
||||
}
|
||||
|
||||
private func createConnectionError(withChannelName channelName: String) -> PigeonError {
|
||||
return PigeonError(code: "channel-error", message: "Unable to establish connection on channel: '\(channelName)'.", details: "")
|
||||
}
|
||||
|
||||
private func isNullish(_ value: Any?) -> Bool {
|
||||
return value is NSNull || value == nil
|
||||
}
|
||||
|
||||
private func nilOrValue<T>(_ value: Any?) -> T? {
|
||||
if value is NSNull { return nil }
|
||||
return value as! T?
|
||||
}
|
||||
|
||||
func deepEqualsBackgroundWorker(_ lhs: Any?, _ rhs: Any?) -> Bool {
|
||||
let cleanLhs = nilOrValue(lhs) as Any?
|
||||
let cleanRhs = nilOrValue(rhs) as Any?
|
||||
switch (cleanLhs, cleanRhs) {
|
||||
case (nil, nil):
|
||||
return true
|
||||
|
||||
case (nil, _), (_, nil):
|
||||
return false
|
||||
|
||||
case is (Void, Void):
|
||||
return true
|
||||
|
||||
case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable):
|
||||
return cleanLhsHashable == cleanRhsHashable
|
||||
|
||||
case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]):
|
||||
guard cleanLhsArray.count == cleanRhsArray.count else { return false }
|
||||
for (index, element) in cleanLhsArray.enumerated() {
|
||||
if !deepEqualsBackgroundWorker(element, cleanRhsArray[index]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
|
||||
case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]):
|
||||
guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false }
|
||||
for (key, cleanLhsValue) in cleanLhsDictionary {
|
||||
guard cleanRhsDictionary.index(forKey: key) != nil else { return false }
|
||||
if !deepEqualsBackgroundWorker(cleanLhsValue, cleanRhsDictionary[key]!) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
|
||||
default:
|
||||
// Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func deepHashBackgroundWorker(value: Any?, hasher: inout Hasher) {
|
||||
if let valueList = value as? [AnyHashable] {
|
||||
for item in valueList { deepHashBackgroundWorker(value: item, hasher: &hasher) }
|
||||
return
|
||||
}
|
||||
|
||||
if let valueDict = value as? [AnyHashable: AnyHashable] {
|
||||
for key in valueDict.keys {
|
||||
hasher.combine(key)
|
||||
deepHashBackgroundWorker(value: valueDict[key]!, hasher: &hasher)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let hashableValue = value as? AnyHashable {
|
||||
hasher.combine(hashableValue.hashValue)
|
||||
}
|
||||
|
||||
return hasher.combine(String(describing: value))
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Generated class from Pigeon that represents data sent in messages.
|
||||
struct BackgroundWorkerSettings: Hashable {
|
||||
var requiresCharging: Bool
|
||||
var minimumDelaySeconds: Int64
|
||||
|
||||
|
||||
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||
static func fromList(_ pigeonVar_list: [Any?]) -> BackgroundWorkerSettings? {
|
||||
let requiresCharging = pigeonVar_list[0] as! Bool
|
||||
let minimumDelaySeconds = pigeonVar_list[1] as! Int64
|
||||
|
||||
return BackgroundWorkerSettings(
|
||||
requiresCharging: requiresCharging,
|
||||
minimumDelaySeconds: minimumDelaySeconds
|
||||
)
|
||||
}
|
||||
func toList() -> [Any?] {
|
||||
return [
|
||||
requiresCharging,
|
||||
minimumDelaySeconds,
|
||||
]
|
||||
}
|
||||
static func == (lhs: BackgroundWorkerSettings, rhs: BackgroundWorkerSettings) -> Bool {
|
||||
return deepEqualsBackgroundWorker(lhs.toList(), rhs.toList()) }
|
||||
func hash(into hasher: inout Hasher) {
|
||||
deepHashBackgroundWorker(value: toList(), hasher: &hasher)
|
||||
}
|
||||
}
|
||||
|
||||
private class BackgroundWorkerPigeonCodecReader: FlutterStandardReader {
|
||||
override func readValue(ofType type: UInt8) -> Any? {
|
||||
switch type {
|
||||
case 129:
|
||||
return BackgroundWorkerSettings.fromList(self.readValue() as! [Any?])
|
||||
default:
|
||||
return super.readValue(ofType: type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class BackgroundWorkerPigeonCodecWriter: FlutterStandardWriter {
|
||||
override func writeValue(_ value: Any) {
|
||||
if let value = value as? BackgroundWorkerSettings {
|
||||
super.writeByte(129)
|
||||
super.writeValue(value.toList())
|
||||
} else {
|
||||
super.writeValue(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class BackgroundWorkerPigeonCodecReaderWriter: FlutterStandardReaderWriter {
|
||||
override func reader(with data: Data) -> FlutterStandardReader {
|
||||
return BackgroundWorkerPigeonCodecReader(data: data)
|
||||
}
|
||||
|
||||
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
|
||||
return BackgroundWorkerPigeonCodecWriter(data: data)
|
||||
}
|
||||
}
|
||||
|
||||
class BackgroundWorkerPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
|
||||
static let shared = BackgroundWorkerPigeonCodec(readerWriter: BackgroundWorkerPigeonCodecReaderWriter())
|
||||
}
|
||||
|
||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||
protocol BackgroundWorkerFgHostApi {
|
||||
func enable() throws
|
||||
func saveNotificationMessage(title: String, body: String) throws
|
||||
func configure(settings: BackgroundWorkerSettings) throws
|
||||
func disable() throws
|
||||
}
|
||||
|
||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||
class BackgroundWorkerFgHostApiSetup {
|
||||
static var codec: FlutterStandardMessageCodec { BackgroundWorkerPigeonCodec.shared }
|
||||
/// 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)
|
||||
if let api = api {
|
||||
enableChannel.setMessageHandler { _, reply in
|
||||
do {
|
||||
try api.enable()
|
||||
reply(wrapResult(nil))
|
||||
} catch {
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
enableChannel.setMessageHandler(nil)
|
||||
}
|
||||
let saveNotificationMessageChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.saveNotificationMessage\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
saveNotificationMessageChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let titleArg = args[0] as! String
|
||||
let bodyArg = args[1] as! String
|
||||
do {
|
||||
try api.saveNotificationMessage(title: titleArg, body: bodyArg)
|
||||
reply(wrapResult(nil))
|
||||
} catch {
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
saveNotificationMessageChannel.setMessageHandler(nil)
|
||||
}
|
||||
let configureChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.configure\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
configureChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let settingsArg = args[0] as! BackgroundWorkerSettings
|
||||
do {
|
||||
try api.configure(settings: settingsArg)
|
||||
reply(wrapResult(nil))
|
||||
} catch {
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
configureChannel.setMessageHandler(nil)
|
||||
}
|
||||
let disableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
disableChannel.setMessageHandler { _, reply in
|
||||
do {
|
||||
try api.disable()
|
||||
reply(wrapResult(nil))
|
||||
} catch {
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
disableChannel.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`.
|
||||
class BackgroundWorkerBgHostApiSetup {
|
||||
static var codec: FlutterStandardMessageCodec { BackgroundWorkerPigeonCodec.shared }
|
||||
/// Sets up an instance of `BackgroundWorkerBgHostApi` to handle messages through the `binaryMessenger`.
|
||||
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: BackgroundWorkerBgHostApi?, messageChannelSuffix: String = "") {
|
||||
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
|
||||
let onInitializedChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.onInitialized\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
onInitializedChannel.setMessageHandler { _, reply in
|
||||
do {
|
||||
try api.onInitialized()
|
||||
reply(wrapResult(nil))
|
||||
} catch {
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
} 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 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)
|
||||
}
|
||||
class BackgroundWorkerFlutterApi: BackgroundWorkerFlutterApiProtocol {
|
||||
private let binaryMessenger: FlutterBinaryMessenger
|
||||
private let messageChannelSuffix: String
|
||||
init(binaryMessenger: FlutterBinaryMessenger, messageChannelSuffix: String = "") {
|
||||
self.binaryMessenger = binaryMessenger
|
||||
self.messageChannelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
|
||||
}
|
||||
var codec: BackgroundWorkerPigeonCodec {
|
||||
return BackgroundWorkerPigeonCodec.shared
|
||||
}
|
||||
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)
|
||||
channel.sendMessage([isRefreshArg, 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 onAndroidUpload(completion: @escaping (Result<Void, PigeonError>) -> Void) {
|
||||
let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onAndroidUpload\(messageChannelSuffix)"
|
||||
let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec)
|
||||
channel.sendMessage(nil) { 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 cancel(completion: @escaping (Result<Void, PigeonError>) -> Void) {
|
||||
let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.cancel\(messageChannelSuffix)"
|
||||
let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec)
|
||||
channel.sendMessage(nil) { 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(()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import BackgroundTasks
|
||||
import Flutter
|
||||
|
||||
enum BackgroundTaskType { case refresh, processing }
|
||||
|
||||
/*
|
||||
* DEBUG: Testing Background Tasks in Xcode
|
||||
*
|
||||
* 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 background refresh (short-running sync):
|
||||
|
||||
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"app.alextran.immich.background.refreshUpload"]
|
||||
|
||||
## For background processing (long-running upload):
|
||||
|
||||
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"app.alextran.immich.background.processingUpload"]
|
||||
|
||||
* To simulate task expiration (useful for testing expiration handlers):
|
||||
|
||||
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"app.alextran.immich.background.refreshUpload"]
|
||||
|
||||
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"app.alextran.immich.background.processingUpload"]
|
||||
|
||||
* 3. Resume the application to see the background code execute
|
||||
*
|
||||
* NOTE: This must be tested on a physical device, not in the simulator.
|
||||
* In testing, only the background processing task can be reliably simulated.
|
||||
* These commands submit the respective task to BGTaskScheduler for immediate processing.
|
||||
* Use the expiration commands to test how the app handles iOS terminating background tasks.
|
||||
*/
|
||||
|
||||
|
||||
/// The background worker which creates a new Flutter VM, communicates with it
|
||||
/// to run the backup job, and then finishes execution and calls back to its callback handler.
|
||||
/// This class manages a separate Flutter engine instance for background execution,
|
||||
/// independent of the main UI Flutter engine.
|
||||
class BackgroundWorker: BackgroundWorkerBgHostApi {
|
||||
private let taskType: BackgroundTaskType
|
||||
/// The maximum number of seconds to run the task before timing out
|
||||
private let maxSeconds: Int?
|
||||
/// Callback function to invoke when the background task completes
|
||||
private let completionHandler: (_ success: Bool) -> Void
|
||||
|
||||
/// The Flutter engine created specifically for background execution.
|
||||
/// This is a separate instance from the main Flutter engine that handles the UI.
|
||||
/// It operates in its own isolate and doesn't share memory with the main engine.
|
||||
/// Must be properly started, registered, and torn down during background execution.
|
||||
private let engine = FlutterEngine(name: "BackgroundImmich")
|
||||
|
||||
/// Used to call methods on the flutter side
|
||||
private var flutterApi: BackgroundWorkerFlutterApi?
|
||||
|
||||
/// Flag to track whether the background task has completed to prevent duplicate completions
|
||||
private var isComplete = false
|
||||
|
||||
/**
|
||||
* Initializes a new background worker with the specified task type and execution constraints.
|
||||
* Creates a new Flutter engine instance for background execution and sets up the necessary
|
||||
* communication channels between native iOS and Flutter code.
|
||||
*
|
||||
* - Parameters:
|
||||
* - taskType: The type of background task to execute (upload or sync task)
|
||||
* - maxSeconds: Optional maximum execution time in seconds before the task is cancelled
|
||||
* - completionHandler: Callback function invoked when the task completes, with success status
|
||||
*/
|
||||
init(taskType: BackgroundTaskType, maxSeconds: Int?, completionHandler: @escaping (_ success: Bool) -> Void) {
|
||||
self.taskType = taskType
|
||||
self.maxSeconds = maxSeconds
|
||||
self.completionHandler = completionHandler
|
||||
// Should be initialized only after the engine starts running
|
||||
self.flutterApi = nil
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the background Flutter engine and begins execution of the background task.
|
||||
* Retrieves the callback handle from UserDefaults, looks up the Flutter callback,
|
||||
* starts the engine, and sets up a timeout timer if specified.
|
||||
*/
|
||||
func run() {
|
||||
// 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"
|
||||
)
|
||||
|
||||
// Verify that the Flutter engine started successfully
|
||||
if !isRunning {
|
||||
complete(success: false)
|
||||
return
|
||||
}
|
||||
|
||||
// Register plugins in the new engine
|
||||
GeneratedPluginRegistrant.register(with: engine)
|
||||
// Register custom plugins
|
||||
AppDelegate.registerPlugins(binaryMessenger: engine.binaryMessenger)
|
||||
flutterApi = BackgroundWorkerFlutterApi(binaryMessenger: engine.binaryMessenger)
|
||||
BackgroundWorkerBgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: self)
|
||||
|
||||
// Set up a timeout timer if maxSeconds was specified to prevent runaway background tasks
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the Flutter side when it has finished initialization and is ready to receive commands.
|
||||
* Routes the appropriate task type (refresh or processing) to the corresponding Flutter method.
|
||||
* 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)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the currently running background task, either due to timeout or external request.
|
||||
* 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() {
|
||||
if isComplete {
|
||||
return
|
||||
}
|
||||
|
||||
flutterApi?.cancel { result in
|
||||
self.complete(success: false)
|
||||
}
|
||||
|
||||
// Fallback safety mechanism: ensure completion is called within 2 seconds
|
||||
// This prevents the background task from hanging indefinitely if Flutter doesn't respond
|
||||
Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in
|
||||
self.complete(success: false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handles the result from Flutter API calls and determines the success/failure status.
|
||||
* Converts Flutter's Result type to a simple boolean success indicator for task completion.
|
||||
*
|
||||
* - Parameter result: The result returned from a Flutter API call
|
||||
*/
|
||||
private func handleHostResult(result: Result<Void, PigeonError>) {
|
||||
switch result {
|
||||
case .success(): self.complete(success: true)
|
||||
case .failure(_): self.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up resources by destroying the Flutter engine context and invokes the completion handler.
|
||||
* This method ensures that the background task is marked as complete, releases the Flutter engine,
|
||||
* and notifies the caller of the task's success or failure. This is the final step in the
|
||||
* background task lifecycle and should only be called once per task instance.
|
||||
*
|
||||
* - Parameter success: Indicates whether the background task completed successfully
|
||||
*/
|
||||
private func complete(success: Bool) {
|
||||
if(isComplete) {
|
||||
return
|
||||
}
|
||||
|
||||
isComplete = true
|
||||
engine.destroyContext()
|
||||
flutterApi = nil
|
||||
completionHandler(success)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import BackgroundTasks
|
||||
|
||||
class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||
|
||||
func enable() throws {
|
||||
BackgroundWorkerApiImpl.scheduleRefreshWorker()
|
||||
BackgroundWorkerApiImpl.scheduleProcessingWorker()
|
||||
print("BackgroundWorkerApiImpl:enable Background worker scheduled")
|
||||
}
|
||||
|
||||
func configure(settings: BackgroundWorkerSettings) throws {
|
||||
// Android only
|
||||
}
|
||||
|
||||
func saveNotificationMessage(title: String, body: String) throws {
|
||||
// Android only
|
||||
}
|
||||
|
||||
func disable() throws {
|
||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.refreshTaskID);
|
||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.processingTaskID);
|
||||
print("BackgroundWorkerApiImpl:disableUploadWorker Disabled background workers")
|
||||
}
|
||||
|
||||
private static let refreshTaskID = "app.alextran.immich.background.refreshUpload"
|
||||
private static let processingTaskID = "app.alextran.immich.background.processingUpload"
|
||||
private static let taskSemaphore = DispatchSemaphore(value: 1)
|
||||
|
||||
public static func registerBackgroundWorkers() {
|
||||
BGTaskScheduler.shared.register(
|
||||
forTaskWithIdentifier: processingTaskID, using: nil) { task in
|
||||
if task is BGProcessingTask {
|
||||
handleBackgroundProcessing(task: task as! BGProcessingTask)
|
||||
}
|
||||
}
|
||||
|
||||
BGTaskScheduler.shared.register(
|
||||
forTaskWithIdentifier: refreshTaskID, using: nil) { task in
|
||||
if task is BGAppRefreshTask {
|
||||
handleBackgroundRefresh(task: task as! BGAppRefreshTask)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func scheduleRefreshWorker() {
|
||||
let backgroundRefresh = BGAppRefreshTaskRequest(identifier: refreshTaskID)
|
||||
backgroundRefresh.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60) // 5 mins
|
||||
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(backgroundRefresh)
|
||||
} catch {
|
||||
print("Could not schedule the refresh upload task \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func scheduleProcessingWorker() {
|
||||
let backgroundProcessing = BGProcessingTaskRequest(identifier: processingTaskID)
|
||||
|
||||
backgroundProcessing.requiresNetworkConnectivity = true
|
||||
backgroundProcessing.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 mins
|
||||
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(backgroundProcessing)
|
||||
} catch {
|
||||
print("Could not schedule the processing upload task \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleBackgroundRefresh(task: BGAppRefreshTask) {
|
||||
scheduleRefreshWorker()
|
||||
// If another task is running, cede the background time back to the OS
|
||||
if taskSemaphore.wait(timeout: .now()) == .success {
|
||||
// Restrict the refresh task to run only for a maximum of (maxSeconds) seconds
|
||||
runBackgroundWorker(task: task, taskType: .refresh, maxSeconds: 20)
|
||||
} else {
|
||||
task.setTaskCompleted(success: false)
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleBackgroundProcessing(task: BGProcessingTask) {
|
||||
scheduleProcessingWorker()
|
||||
taskSemaphore.wait()
|
||||
// There are no restrictions for processing tasks. Although, the OS could signal expiration at any time
|
||||
runBackgroundWorker(task: task, taskType: .processing, maxSeconds: nil)
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the background worker within the context of a background task.
|
||||
* This method creates a BackgroundWorker, sets up task expiration handling,
|
||||
* and manages the synchronization between the background task and the Flutter engine.
|
||||
*
|
||||
* - Parameters:
|
||||
* - task: The iOS background task that provides the execution context
|
||||
* - taskType: The type of background operation to perform (refresh or processing)
|
||||
* - maxSeconds: Optional timeout for the operation in seconds
|
||||
*/
|
||||
private static func runBackgroundWorker(task: BGTask, taskType: BackgroundTaskType, maxSeconds: Int?) {
|
||||
defer { taskSemaphore.signal() }
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var isSuccess = true
|
||||
|
||||
let backgroundWorker = BackgroundWorker(taskType: taskType, maxSeconds: maxSeconds) { success in
|
||||
isSuccess = success
|
||||
semaphore.signal()
|
||||
}
|
||||
|
||||
task.expirationHandler = {
|
||||
DispatchQueue.main.async {
|
||||
backgroundWorker.close()
|
||||
}
|
||||
isSuccess = false
|
||||
|
||||
// Schedule a timer to signal the semaphore after 2 seconds
|
||||
Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in
|
||||
semaphore.signal()
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
backgroundWorker.run()
|
||||
}
|
||||
|
||||
semaphore.wait()
|
||||
task.setTaskCompleted(success: isSuccess)
|
||||
print("Background task completed with success: \(isSuccess)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
|
||||
import Foundation
|
||||
|
||||
#if os(iOS)
|
||||
import Flutter
|
||||
#elseif os(macOS)
|
||||
import FlutterMacOS
|
||||
#else
|
||||
#error("Unsupported platform.")
|
||||
#endif
|
||||
|
||||
private func wrapResult(_ result: Any?) -> [Any?] {
|
||||
return [result]
|
||||
}
|
||||
|
||||
private func wrapError(_ error: Any) -> [Any?] {
|
||||
if let pigeonError = error as? PigeonError {
|
||||
return [
|
||||
pigeonError.code,
|
||||
pigeonError.message,
|
||||
pigeonError.details,
|
||||
]
|
||||
}
|
||||
if let flutterError = error as? FlutterError {
|
||||
return [
|
||||
flutterError.code,
|
||||
flutterError.message,
|
||||
flutterError.details,
|
||||
]
|
||||
}
|
||||
return [
|
||||
"\(error)",
|
||||
"\(type(of: error))",
|
||||
"Stacktrace: \(Thread.callStackSymbols)",
|
||||
]
|
||||
}
|
||||
|
||||
private func isNullish(_ value: Any?) -> Bool {
|
||||
return value is NSNull || value == nil
|
||||
}
|
||||
|
||||
private func nilOrValue<T>(_ value: Any?) -> T? {
|
||||
if value is NSNull { return nil }
|
||||
return value as! T?
|
||||
}
|
||||
|
||||
|
||||
enum NetworkCapability: Int {
|
||||
case cellular = 0
|
||||
case wifi = 1
|
||||
case vpn = 2
|
||||
case unmetered = 3
|
||||
}
|
||||
|
||||
private class ConnectivityPigeonCodecReader: FlutterStandardReader {
|
||||
override func readValue(ofType type: UInt8) -> Any? {
|
||||
switch type {
|
||||
case 129:
|
||||
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
|
||||
if let enumResultAsInt = enumResultAsInt {
|
||||
return NetworkCapability(rawValue: enumResultAsInt)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return super.readValue(ofType: type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ConnectivityPigeonCodecWriter: FlutterStandardWriter {
|
||||
override func writeValue(_ value: Any) {
|
||||
if let value = value as? NetworkCapability {
|
||||
super.writeByte(129)
|
||||
super.writeValue(value.rawValue)
|
||||
} else {
|
||||
super.writeValue(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ConnectivityPigeonCodecReaderWriter: FlutterStandardReaderWriter {
|
||||
override func reader(with data: Data) -> FlutterStandardReader {
|
||||
return ConnectivityPigeonCodecReader(data: data)
|
||||
}
|
||||
|
||||
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
|
||||
return ConnectivityPigeonCodecWriter(data: data)
|
||||
}
|
||||
}
|
||||
|
||||
class ConnectivityPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
|
||||
static let shared = ConnectivityPigeonCodec(readerWriter: ConnectivityPigeonCodecReaderWriter())
|
||||
}
|
||||
|
||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||
protocol ConnectivityApi {
|
||||
func getCapabilities() throws -> [NetworkCapability]
|
||||
}
|
||||
|
||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||
class ConnectivityApiSetup {
|
||||
static var codec: FlutterStandardMessageCodec { ConnectivityPigeonCodec.shared }
|
||||
/// Sets up an instance of `ConnectivityApi` to handle messages through the `binaryMessenger`.
|
||||
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: ConnectivityApi?, messageChannelSuffix: String = "") {
|
||||
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
|
||||
#if os(iOS)
|
||||
let taskQueue = binaryMessenger.makeBackgroundTaskQueue?()
|
||||
#else
|
||||
let taskQueue: FlutterTaskQueue? = nil
|
||||
#endif
|
||||
let getCapabilitiesChannel = taskQueue == nil
|
||||
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ConnectivityApi.getCapabilities\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ConnectivityApi.getCapabilities\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||
if let api = api {
|
||||
getCapabilitiesChannel.setMessageHandler { _, reply in
|
||||
do {
|
||||
let result = try api.getCapabilities()
|
||||
reply(wrapResult(result))
|
||||
} catch {
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
getCapabilitiesChannel.setMessageHandler(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
class ConnectivityApiImpl: ConnectivityApi {
|
||||
func getCapabilities() throws -> [NetworkCapability] {
|
||||
[]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// Copyright (c) 2023 Evan Wallace
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
import Foundation
|
||||
|
||||
// NOTE: Swift has an exponential-time type checker and compiling very simple
|
||||
// expressions can easily take many seconds, especially when expressions involve
|
||||
// numeric type constructors.
|
||||
//
|
||||
// This file deliberately breaks compound expressions up into separate variables
|
||||
// to improve compile time even though this comes at the expense of readability.
|
||||
// This is a known workaround for this deficiency in the Swift compiler.
|
||||
//
|
||||
// The following command is helpful when debugging Swift compile time issues:
|
||||
//
|
||||
// swiftc ThumbHash.swift -Xfrontend -debug-time-function-bodies
|
||||
//
|
||||
// These optimizations brought the compile time for this file from around 2.5
|
||||
// seconds to around 250ms (10x faster).
|
||||
|
||||
// NOTE: Swift's debug-build performance of for-in loops over numeric ranges is
|
||||
// really awful. Debug builds compile a very generic indexing iterator thing
|
||||
// that makes many nested calls for every iteration, which makes debug-build
|
||||
// performance crawl.
|
||||
//
|
||||
// This file deliberately avoids for-in loops that loop for more than a few
|
||||
// times to improve debug-build run time even though this comes at the expense
|
||||
// of readability. Similarly unsafe pointers are used instead of array getters
|
||||
// to avoid unnecessary bounds checks, which have extra overhead in debug builds.
|
||||
//
|
||||
// These optimizations brought the run time to encode and decode 10 ThumbHashes
|
||||
// in debug mode from 700ms to 70ms (10x faster).
|
||||
|
||||
// changed signature and allocation method to avoid automatic GC
|
||||
func thumbHashToRGBA(hash: Data) -> (Int, Int, UnsafeMutableRawBufferPointer) {
|
||||
// Read the constants
|
||||
let h0 = UInt32(hash[0])
|
||||
let h1 = UInt32(hash[1])
|
||||
let h2 = UInt32(hash[2])
|
||||
let h3 = UInt16(hash[3])
|
||||
let h4 = UInt16(hash[4])
|
||||
let header24 = h0 | (h1 << 8) | (h2 << 16)
|
||||
let header16 = h3 | (h4 << 8)
|
||||
let il_dc = header24 & 63
|
||||
let ip_dc = (header24 >> 6) & 63
|
||||
let iq_dc = (header24 >> 12) & 63
|
||||
var l_dc = Float32(il_dc)
|
||||
var p_dc = Float32(ip_dc)
|
||||
var q_dc = Float32(iq_dc)
|
||||
l_dc = l_dc / 63
|
||||
p_dc = p_dc / 31.5 - 1
|
||||
q_dc = q_dc / 31.5 - 1
|
||||
let il_scale = (header24 >> 18) & 31
|
||||
var l_scale = Float32(il_scale)
|
||||
l_scale = l_scale / 31
|
||||
let hasAlpha = (header24 >> 23) != 0
|
||||
let ip_scale = (header16 >> 3) & 63
|
||||
let iq_scale = (header16 >> 9) & 63
|
||||
var p_scale = Float32(ip_scale)
|
||||
var q_scale = Float32(iq_scale)
|
||||
p_scale = p_scale / 63
|
||||
q_scale = q_scale / 63
|
||||
let isLandscape = (header16 >> 15) != 0
|
||||
let lx16 = max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7)
|
||||
let ly16 = max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7)
|
||||
let lx = Int(lx16)
|
||||
let ly = Int(ly16)
|
||||
var a_dc = Float32(1)
|
||||
var a_scale = Float32(1)
|
||||
if hasAlpha {
|
||||
let ia_dc = hash[5] & 15
|
||||
let ia_scale = hash[5] >> 4
|
||||
a_dc = Float32(ia_dc)
|
||||
a_scale = Float32(ia_scale)
|
||||
a_dc /= 15
|
||||
a_scale /= 15
|
||||
}
|
||||
|
||||
// Read the varying factors (boost saturation by 1.25x to compensate for quantization)
|
||||
let ac_start = hasAlpha ? 6 : 5
|
||||
var ac_index = 0
|
||||
let decodeChannel = { (nx: Int, ny: Int, scale: Float32) -> [Float32] in
|
||||
var ac: [Float32] = []
|
||||
for cy in 0 ..< ny {
|
||||
var cx = cy > 0 ? 0 : 1
|
||||
while cx * ny < nx * (ny - cy) {
|
||||
let iac = (hash[ac_start + (ac_index >> 1)] >> ((ac_index & 1) << 2)) & 15;
|
||||
var fac = Float32(iac)
|
||||
fac = (fac / 7.5 - 1) * scale
|
||||
ac.append(fac)
|
||||
ac_index += 1
|
||||
cx += 1
|
||||
}
|
||||
}
|
||||
return ac
|
||||
}
|
||||
let l_ac = decodeChannel(lx, ly, l_scale)
|
||||
let p_ac = decodeChannel(3, 3, p_scale * 1.25)
|
||||
let q_ac = decodeChannel(3, 3, q_scale * 1.25)
|
||||
let a_ac = hasAlpha ? decodeChannel(5, 5, a_scale) : []
|
||||
|
||||
// Decode using the DCT into RGB
|
||||
let ratio = thumbHashToApproximateAspectRatio(hash: hash)
|
||||
let fw = round(ratio > 1 ? 32 : 32 * ratio)
|
||||
let fh = round(ratio > 1 ? 32 / ratio : 32)
|
||||
let w = Int(fw)
|
||||
let h = Int(fh)
|
||||
let pointer = UnsafeMutableRawBufferPointer.allocate(
|
||||
byteCount: w * h * 4,
|
||||
alignment: MemoryLayout<UInt8>.alignment
|
||||
)
|
||||
var rgba = pointer.baseAddress!.assumingMemoryBound(to: UInt8.self)
|
||||
let cx_stop = max(lx, hasAlpha ? 5 : 3)
|
||||
let cy_stop = max(ly, hasAlpha ? 5 : 3)
|
||||
var fx = [Float32](repeating: 0, count: cx_stop)
|
||||
var fy = [Float32](repeating: 0, count: cy_stop)
|
||||
fx.withUnsafeMutableBytes { fx in
|
||||
let fx = fx.baseAddress!.bindMemory(to: Float32.self, capacity: fx.count)
|
||||
fy.withUnsafeMutableBytes { fy in
|
||||
let fy = fy.baseAddress!.bindMemory(to: Float32.self, capacity: fy.count)
|
||||
var y = 0
|
||||
while y < h {
|
||||
var x = 0
|
||||
while x < w {
|
||||
var l = l_dc
|
||||
var p = p_dc
|
||||
var q = q_dc
|
||||
var a = a_dc
|
||||
|
||||
// Precompute the coefficients
|
||||
var cx = 0
|
||||
while cx < cx_stop {
|
||||
let fw = Float32(w)
|
||||
let fxx = Float32(x)
|
||||
let fcx = Float32(cx)
|
||||
fx[cx] = cos(Float32.pi / fw * (fxx + 0.5) * fcx)
|
||||
cx += 1
|
||||
}
|
||||
var cy = 0
|
||||
while cy < cy_stop {
|
||||
let fh = Float32(h)
|
||||
let fyy = Float32(y)
|
||||
let fcy = Float32(cy)
|
||||
fy[cy] = cos(Float32.pi / fh * (fyy + 0.5) * fcy)
|
||||
cy += 1
|
||||
}
|
||||
|
||||
// Decode L
|
||||
var j = 0
|
||||
cy = 0
|
||||
while cy < ly {
|
||||
var cx = cy > 0 ? 0 : 1
|
||||
let fy2 = fy[cy] * 2
|
||||
while cx * ly < lx * (ly - cy) {
|
||||
l += l_ac[j] * fx[cx] * fy2
|
||||
j += 1
|
||||
cx += 1
|
||||
}
|
||||
cy += 1
|
||||
}
|
||||
|
||||
// Decode P and Q
|
||||
j = 0
|
||||
cy = 0
|
||||
while cy < 3 {
|
||||
var cx = cy > 0 ? 0 : 1
|
||||
let fy2 = fy[cy] * 2
|
||||
while cx < 3 - cy {
|
||||
let f = fx[cx] * fy2
|
||||
p += p_ac[j] * f
|
||||
q += q_ac[j] * f
|
||||
j += 1
|
||||
cx += 1
|
||||
}
|
||||
cy += 1
|
||||
}
|
||||
|
||||
// Decode A
|
||||
if hasAlpha {
|
||||
j = 0
|
||||
cy = 0
|
||||
while cy < 5 {
|
||||
var cx = cy > 0 ? 0 : 1
|
||||
let fy2 = fy[cy] * 2
|
||||
while cx < 5 - cy {
|
||||
a += a_ac[j] * fx[cx] * fy2
|
||||
j += 1
|
||||
cx += 1
|
||||
}
|
||||
cy += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to RGB
|
||||
var b = l - 2 / 3 * p
|
||||
var r = (3 * l - b + q) / 2
|
||||
var g = r - q
|
||||
r = max(0, 255 * min(1, r))
|
||||
g = max(0, 255 * min(1, g))
|
||||
b = max(0, 255 * min(1, b))
|
||||
a = max(0, 255 * min(1, a))
|
||||
rgba[0] = UInt8(r)
|
||||
rgba[1] = UInt8(g)
|
||||
rgba[2] = UInt8(b)
|
||||
rgba[3] = UInt8(a)
|
||||
rgba = rgba.advanced(by: 4)
|
||||
x += 1
|
||||
}
|
||||
y += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return (w, h, pointer)
|
||||
}
|
||||
|
||||
func thumbHashToApproximateAspectRatio(hash: Data) -> Float32 {
|
||||
let header = hash[3]
|
||||
let hasAlpha = (hash[2] & 0x80) != 0
|
||||
let isLandscape = (hash[4] & 0x80) != 0
|
||||
let lx = isLandscape ? hasAlpha ? 5 : 7 : header & 7
|
||||
let ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7
|
||||
return Float32(lx) / Float32(ly)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
|
||||
import Foundation
|
||||
|
||||
#if os(iOS)
|
||||
import Flutter
|
||||
#elseif os(macOS)
|
||||
import FlutterMacOS
|
||||
#else
|
||||
#error("Unsupported platform.")
|
||||
#endif
|
||||
|
||||
private func wrapResult(_ result: Any?) -> [Any?] {
|
||||
return [result]
|
||||
}
|
||||
|
||||
private func wrapError(_ error: Any) -> [Any?] {
|
||||
if let pigeonError = error as? PigeonError {
|
||||
return [
|
||||
pigeonError.code,
|
||||
pigeonError.message,
|
||||
pigeonError.details,
|
||||
]
|
||||
}
|
||||
if let flutterError = error as? FlutterError {
|
||||
return [
|
||||
flutterError.code,
|
||||
flutterError.message,
|
||||
flutterError.details,
|
||||
]
|
||||
}
|
||||
return [
|
||||
"\(error)",
|
||||
"\(type(of: error))",
|
||||
"Stacktrace: \(Thread.callStackSymbols)",
|
||||
]
|
||||
}
|
||||
|
||||
private func isNullish(_ value: Any?) -> Bool {
|
||||
return value is NSNull || value == nil
|
||||
}
|
||||
|
||||
private func nilOrValue<T>(_ value: Any?) -> T? {
|
||||
if value is NSNull { return nil }
|
||||
return value as! T?
|
||||
}
|
||||
|
||||
|
||||
private class ThumbnailsPigeonCodecReader: FlutterStandardReader {
|
||||
}
|
||||
|
||||
private class ThumbnailsPigeonCodecWriter: FlutterStandardWriter {
|
||||
}
|
||||
|
||||
private class ThumbnailsPigeonCodecReaderWriter: FlutterStandardReaderWriter {
|
||||
override func reader(with data: Data) -> FlutterStandardReader {
|
||||
return ThumbnailsPigeonCodecReader(data: data)
|
||||
}
|
||||
|
||||
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
|
||||
return ThumbnailsPigeonCodecWriter(data: data)
|
||||
}
|
||||
}
|
||||
|
||||
class ThumbnailsPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
|
||||
static let shared = ThumbnailsPigeonCodec(readerWriter: ThumbnailsPigeonCodecReaderWriter())
|
||||
}
|
||||
|
||||
|
||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||
protocol ThumbnailApi {
|
||||
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64], Error>) -> Void)
|
||||
func cancelImageRequest(requestId: Int64) throws
|
||||
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String: Int64], Error>) -> Void)
|
||||
}
|
||||
|
||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||
class ThumbnailApiSetup {
|
||||
static var codec: FlutterStandardMessageCodec { ThumbnailsPigeonCodec.shared }
|
||||
/// Sets up an instance of `ThumbnailApi` to handle messages through the `binaryMessenger`.
|
||||
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: ThumbnailApi?, messageChannelSuffix: String = "") {
|
||||
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
|
||||
let requestImageChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ThumbnailApi.requestImage\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
requestImageChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let assetIdArg = args[0] as! String
|
||||
let requestIdArg = args[1] as! Int64
|
||||
let widthArg = args[2] as! Int64
|
||||
let heightArg = args[3] as! Int64
|
||||
let isVideoArg = args[4] as! Bool
|
||||
api.requestImage(assetId: assetIdArg, requestId: requestIdArg, width: widthArg, height: heightArg, isVideo: isVideoArg) { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
requestImageChannel.setMessageHandler(nil)
|
||||
}
|
||||
let cancelImageRequestChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ThumbnailApi.cancelImageRequest\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
cancelImageRequestChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let requestIdArg = args[0] as! Int64
|
||||
do {
|
||||
try api.cancelImageRequest(requestId: requestIdArg)
|
||||
reply(wrapResult(nil))
|
||||
} catch {
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cancelImageRequestChannel.setMessageHandler(nil)
|
||||
}
|
||||
let getThumbhashChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ThumbnailApi.getThumbhash\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
getThumbhashChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let thumbhashArg = args[0] as! String
|
||||
api.getThumbhash(thumbhash: thumbhashArg) { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
getThumbhashChannel.setMessageHandler(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import CryptoKit
|
||||
import Flutter
|
||||
import MobileCoreServices
|
||||
import Photos
|
||||
|
||||
class Request {
|
||||
weak var workItem: DispatchWorkItem?
|
||||
var isCancelled = false
|
||||
let callback: (Result<[String: Int64], any Error>) -> Void
|
||||
|
||||
init(callback: @escaping (Result<[String: Int64], any Error>) -> Void) {
|
||||
self.callback = callback
|
||||
}
|
||||
}
|
||||
|
||||
class ThumbnailApiImpl: ThumbnailApi {
|
||||
private static let imageManager = PHImageManager.default()
|
||||
private static let fetchOptions = {
|
||||
let fetchOptions = PHFetchOptions()
|
||||
fetchOptions.fetchLimit = 1
|
||||
fetchOptions.wantsIncrementalChangeDetails = false
|
||||
return fetchOptions
|
||||
}()
|
||||
private static let requestOptions = {
|
||||
let requestOptions = PHImageRequestOptions()
|
||||
requestOptions.isNetworkAccessAllowed = true
|
||||
requestOptions.deliveryMode = .highQualityFormat
|
||||
requestOptions.resizeMode = .fast
|
||||
requestOptions.isSynchronous = true
|
||||
requestOptions.version = .current
|
||||
return requestOptions
|
||||
}()
|
||||
|
||||
private static let assetQueue = DispatchQueue(label: "thumbnail.assets", qos: .userInitiated)
|
||||
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated)
|
||||
private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default)
|
||||
private static let processingQueue = DispatchQueue(label: "thumbnail.processing", qos: .userInteractive, attributes: .concurrent)
|
||||
|
||||
private static let rgbColorSpace = CGColorSpaceCreateDeviceRGB()
|
||||
private static let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).rawValue
|
||||
private static var requests = [Int64: Request]()
|
||||
private static let cancelledResult = Result<[String: Int64], any Error>.success([:])
|
||||
private static let concurrencySemaphore = DispatchSemaphore(value: ProcessInfo.processInfo.activeProcessorCount * 2)
|
||||
private static let assetCache = {
|
||||
let assetCache = NSCache<NSString, PHAsset>()
|
||||
assetCache.countLimit = 10000
|
||||
return assetCache
|
||||
}()
|
||||
private static let activitySemaphore = DispatchSemaphore(value: 1)
|
||||
private static let willResignActiveObserver = NotificationCenter.default.addObserver(
|
||||
forName: UIApplication.willResignActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { _ in
|
||||
processingQueue.suspend()
|
||||
activitySemaphore.wait()
|
||||
}
|
||||
private static let didBecomeActiveObserver = NotificationCenter.default.addObserver(
|
||||
forName: UIApplication.didBecomeActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { _ in
|
||||
processingQueue.resume()
|
||||
activitySemaphore.signal()
|
||||
}
|
||||
|
||||
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
|
||||
Self.processingQueue.async {
|
||||
guard let data = Data(base64Encoded: thumbhash)
|
||||
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
|
||||
|
||||
let (width, height, pointer) = thumbHashToRGBA(hash: data)
|
||||
self.waitForActiveState()
|
||||
completion(.success(["pointer": Int64(Int(bitPattern: pointer.baseAddress)), "width": Int64(width), "height": Int64(height)]))
|
||||
}
|
||||
}
|
||||
|
||||
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64], any Error>) -> Void) {
|
||||
let request = Request(callback: completion)
|
||||
let item = DispatchWorkItem {
|
||||
if request.isCancelled {
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
Self.concurrencySemaphore.wait()
|
||||
defer {
|
||||
Self.concurrencySemaphore.signal()
|
||||
}
|
||||
|
||||
if request.isCancelled {
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
guard let asset = Self.requestAsset(assetId: assetId)
|
||||
else {
|
||||
Self.removeRequest(requestId: requestId)
|
||||
completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil)))
|
||||
return
|
||||
}
|
||||
|
||||
if request.isCancelled {
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
var image: UIImage?
|
||||
Self.imageManager.requestImage(
|
||||
for: asset,
|
||||
targetSize: width > 0 && height > 0 ? CGSize(width: Double(width), height: Double(height)) : PHImageManagerMaximumSize,
|
||||
contentMode: .aspectFill,
|
||||
options: Self.requestOptions,
|
||||
resultHandler: { (_image, info) -> Void in
|
||||
image = _image
|
||||
}
|
||||
)
|
||||
|
||||
if request.isCancelled {
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
guard let image = image,
|
||||
let cgImage = image.cgImage else {
|
||||
Self.removeRequest(requestId: requestId)
|
||||
return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
|
||||
}
|
||||
|
||||
let pointer = UnsafeMutableRawPointer.allocate(
|
||||
byteCount: Int(cgImage.width) * Int(cgImage.height) * 4,
|
||||
alignment: MemoryLayout<UInt8>.alignment
|
||||
)
|
||||
|
||||
if request.isCancelled {
|
||||
pointer.deallocate()
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
guard let context = CGContext(
|
||||
data: pointer,
|
||||
width: cgImage.width,
|
||||
height: cgImage.height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: cgImage.width * 4,
|
||||
space: Self.rgbColorSpace,
|
||||
bitmapInfo: Self.bitmapInfo
|
||||
) else {
|
||||
pointer.deallocate()
|
||||
Self.removeRequest(requestId: requestId)
|
||||
return completion(.failure(PigeonError(code: "", message: "Could not create context for \(assetId)", details: nil)))
|
||||
}
|
||||
|
||||
if request.isCancelled {
|
||||
pointer.deallocate()
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
context.interpolationQuality = .none
|
||||
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height))
|
||||
|
||||
if request.isCancelled {
|
||||
pointer.deallocate()
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
self.waitForActiveState()
|
||||
completion(.success(["pointer": Int64(Int(bitPattern: pointer)), "width": Int64(cgImage.width), "height": Int64(cgImage.height)]))
|
||||
Self.removeRequest(requestId: requestId)
|
||||
}
|
||||
|
||||
request.workItem = item
|
||||
Self.addRequest(requestId: requestId, request: request)
|
||||
Self.processingQueue.async(execute: item)
|
||||
}
|
||||
|
||||
func cancelImageRequest(requestId: Int64) {
|
||||
Self.cancelRequest(requestId: requestId)
|
||||
}
|
||||
|
||||
private static func addRequest(requestId: Int64, request: Request) -> Void {
|
||||
requestQueue.sync { requests[requestId] = request }
|
||||
}
|
||||
|
||||
private static func removeRequest(requestId: Int64) -> Void {
|
||||
requestQueue.sync { requests[requestId] = nil }
|
||||
}
|
||||
|
||||
private static func cancelRequest(requestId: Int64) -> Void {
|
||||
requestQueue.async {
|
||||
guard let request = requests.removeValue(forKey: requestId) else { return }
|
||||
request.isCancelled = true
|
||||
guard let item = request.workItem else { return }
|
||||
if item.isCancelled {
|
||||
cancelQueue.async { request.callback(Self.cancelledResult) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func requestAsset(assetId: String) -> PHAsset? {
|
||||
var asset: PHAsset?
|
||||
assetQueue.sync { asset = assetCache.object(forKey: assetId as NSString) }
|
||||
if asset != nil { return asset }
|
||||
|
||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions).firstObject
|
||||
else { return nil }
|
||||
assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) }
|
||||
return asset
|
||||
}
|
||||
|
||||
func waitForActiveState() {
|
||||
Self.activitySemaphore.wait()
|
||||
Self.activitySemaphore.signal()
|
||||
}
|
||||
}
|
||||
+185
-183
@@ -1,187 +1,189 @@
|
||||
<?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.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.135.1</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>210</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>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 />
|
||||
<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.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>2.0.1</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>230</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>
|
||||
</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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Autogenerated from Pigeon (v25.3.2), do not edit directly.
|
||||
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
|
||||
import Foundation
|
||||
@@ -139,6 +139,7 @@ struct PlatformAsset: Hashable {
|
||||
var height: Int64? = nil
|
||||
var durationInSeconds: Int64
|
||||
var orientation: Int64
|
||||
var isFavorite: Bool
|
||||
|
||||
|
||||
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||
@@ -152,6 +153,7 @@ struct PlatformAsset: Hashable {
|
||||
let height: Int64? = nilOrValue(pigeonVar_list[6])
|
||||
let durationInSeconds = pigeonVar_list[7] as! Int64
|
||||
let orientation = pigeonVar_list[8] as! Int64
|
||||
let isFavorite = pigeonVar_list[9] as! Bool
|
||||
|
||||
return PlatformAsset(
|
||||
id: id,
|
||||
@@ -162,7 +164,8 @@ struct PlatformAsset: Hashable {
|
||||
width: width,
|
||||
height: height,
|
||||
durationInSeconds: durationInSeconds,
|
||||
orientation: orientation
|
||||
orientation: orientation,
|
||||
isFavorite: isFavorite
|
||||
)
|
||||
}
|
||||
func toList() -> [Any?] {
|
||||
@@ -176,6 +179,7 @@ struct PlatformAsset: Hashable {
|
||||
height,
|
||||
durationInSeconds,
|
||||
orientation,
|
||||
isFavorite,
|
||||
]
|
||||
}
|
||||
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {
|
||||
@@ -263,6 +267,39 @@ struct SyncDelta: Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Generated class from Pigeon that represents data sent in messages.
|
||||
struct HashResult: Hashable {
|
||||
var assetId: String
|
||||
var error: String? = nil
|
||||
var hash: String? = nil
|
||||
|
||||
|
||||
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||
static func fromList(_ pigeonVar_list: [Any?]) -> HashResult? {
|
||||
let assetId = pigeonVar_list[0] as! String
|
||||
let error: String? = nilOrValue(pigeonVar_list[1])
|
||||
let hash: String? = nilOrValue(pigeonVar_list[2])
|
||||
|
||||
return HashResult(
|
||||
assetId: assetId,
|
||||
error: error,
|
||||
hash: hash
|
||||
)
|
||||
}
|
||||
func toList() -> [Any?] {
|
||||
return [
|
||||
assetId,
|
||||
error,
|
||||
hash,
|
||||
]
|
||||
}
|
||||
static func == (lhs: HashResult, rhs: HashResult) -> Bool {
|
||||
return deepEqualsMessages(lhs.toList(), rhs.toList()) }
|
||||
func hash(into hasher: inout Hasher) {
|
||||
deepHashMessages(value: toList(), hasher: &hasher)
|
||||
}
|
||||
}
|
||||
|
||||
private class MessagesPigeonCodecReader: FlutterStandardReader {
|
||||
override func readValue(ofType type: UInt8) -> Any? {
|
||||
switch type {
|
||||
@@ -272,6 +309,8 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
|
||||
return PlatformAlbum.fromList(self.readValue() as! [Any?])
|
||||
case 131:
|
||||
return SyncDelta.fromList(self.readValue() as! [Any?])
|
||||
case 132:
|
||||
return HashResult.fromList(self.readValue() as! [Any?])
|
||||
default:
|
||||
return super.readValue(ofType: type)
|
||||
}
|
||||
@@ -289,6 +328,9 @@ private class MessagesPigeonCodecWriter: FlutterStandardWriter {
|
||||
} else if let value = value as? SyncDelta {
|
||||
super.writeByte(131)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? HashResult {
|
||||
super.writeByte(132)
|
||||
super.writeValue(value.toList())
|
||||
} else {
|
||||
super.writeValue(value)
|
||||
}
|
||||
@@ -309,6 +351,7 @@ class MessagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
|
||||
static let shared = MessagesPigeonCodec(readerWriter: MessagesPigeonCodecReaderWriter())
|
||||
}
|
||||
|
||||
|
||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||
protocol NativeSyncApi {
|
||||
func shouldFullSync() throws -> Bool
|
||||
@@ -319,7 +362,8 @@ protocol NativeSyncApi {
|
||||
func getAlbums() throws -> [PlatformAlbum]
|
||||
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64
|
||||
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
|
||||
func hashPaths(paths: [String]) throws -> [FlutterStandardTypedData?]
|
||||
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
|
||||
func cancelHashing() throws
|
||||
}
|
||||
|
||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||
@@ -455,22 +499,38 @@ class NativeSyncApiSetup {
|
||||
} else {
|
||||
getAssetsForAlbumChannel.setMessageHandler(nil)
|
||||
}
|
||||
let hashPathsChannel = taskQueue == nil
|
||||
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||
let hashAssetsChannel = taskQueue == nil
|
||||
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||
if let api = api {
|
||||
hashPathsChannel.setMessageHandler { message, reply in
|
||||
hashAssetsChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let pathsArg = args[0] as! [String]
|
||||
let assetIdsArg = args[0] as! [String]
|
||||
let allowNetworkAccessArg = args[1] as! Bool
|
||||
api.hashAssets(assetIds: assetIdsArg, allowNetworkAccess: allowNetworkAccessArg) { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hashAssetsChannel.setMessageHandler(nil)
|
||||
}
|
||||
let cancelHashingChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
cancelHashingChannel.setMessageHandler { _, reply in
|
||||
do {
|
||||
let result = try api.hashPaths(paths: pathsArg)
|
||||
reply(wrapResult(result))
|
||||
try api.cancelHashing()
|
||||
reply(wrapResult(nil))
|
||||
} catch {
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hashPathsChannel.setMessageHandler(nil)
|
||||
cancelHashingChannel.setMessageHandler(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,29 +17,16 @@ struct AssetWrapper: Hashable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
extension PHAsset {
|
||||
func toPlatformAsset() -> PlatformAsset {
|
||||
return PlatformAsset(
|
||||
id: localIdentifier,
|
||||
name: title(),
|
||||
type: Int64(mediaType.rawValue),
|
||||
createdAt: creationDate.map { Int64($0.timeIntervalSince1970) },
|
||||
updatedAt: modificationDate.map { Int64($0.timeIntervalSince1970) },
|
||||
width: Int64(pixelWidth),
|
||||
height: Int64(pixelHeight),
|
||||
durationInSeconds: Int64(duration),
|
||||
orientation: 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class NativeSyncApiImpl: NativeSyncApi {
|
||||
private let defaults: UserDefaults
|
||||
private let changeTokenKey = "immich:changeToken"
|
||||
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
|
||||
private let recoveredAlbumSubType = 1000000219
|
||||
|
||||
private let hashBufferSize = 2 * 1024 * 1024
|
||||
private var hashTask: Task<Void, Error>?
|
||||
private static let hashCancelledCode = "HASH_CANCELLED"
|
||||
private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil))
|
||||
|
||||
|
||||
init(with defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
@@ -95,7 +82,7 @@ class NativeSyncApiImpl: NativeSyncApi {
|
||||
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
|
||||
for i in 0..<collections.count {
|
||||
let album = collections.object(at: i)
|
||||
|
||||
|
||||
// Ignore recovered album
|
||||
if(album.assetCollectionSubtype.rawValue == self.recoveredAlbumSubType) {
|
||||
continue;
|
||||
@@ -171,7 +158,8 @@ class NativeSyncApiImpl: NativeSyncApi {
|
||||
name: "",
|
||||
type: 0,
|
||||
durationInSeconds: 0,
|
||||
orientation: 0
|
||||
orientation: 0,
|
||||
isFavorite: false
|
||||
)
|
||||
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
|
||||
continue
|
||||
@@ -252,7 +240,7 @@ class NativeSyncApiImpl: NativeSyncApi {
|
||||
let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!))
|
||||
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
|
||||
}
|
||||
|
||||
|
||||
let result = PHAsset.fetchAssets(in: album, options: options)
|
||||
if(result.count == 0) {
|
||||
return []
|
||||
@@ -265,23 +253,114 @@ class NativeSyncApiImpl: NativeSyncApi {
|
||||
return assets
|
||||
}
|
||||
|
||||
func hashPaths(paths: [String]) throws -> [FlutterStandardTypedData?] {
|
||||
return paths.map { path in
|
||||
guard let file = FileHandle(forReadingAtPath: path) else {
|
||||
print("Cannot open file: \(path)")
|
||||
return nil
|
||||
}
|
||||
|
||||
var hasher = Insecure.SHA1()
|
||||
while autoreleasepool(invoking: {
|
||||
let chunk = file.readData(ofLength: hashBufferSize)
|
||||
guard !chunk.isEmpty else { return false }
|
||||
hasher.update(data: chunk)
|
||||
return true
|
||||
}) { }
|
||||
|
||||
let digest = hasher.finalize()
|
||||
return FlutterStandardTypedData(bytes: Data(digest))
|
||||
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) {
|
||||
if let prevTask = hashTask {
|
||||
prevTask.cancel()
|
||||
hashTask = nil
|
||||
}
|
||||
hashTask = Task { [weak self] in
|
||||
var missingAssetIds = Set(assetIds)
|
||||
var assets = [PHAsset]()
|
||||
assets.reserveCapacity(assetIds.count)
|
||||
PHAsset.fetchAssets(withLocalIdentifiers: assetIds, options: nil).enumerateObjects { (asset, _, stop) in
|
||||
if Task.isCancelled {
|
||||
stop.pointee = true
|
||||
return
|
||||
}
|
||||
missingAssetIds.remove(asset.localIdentifier)
|
||||
assets.append(asset)
|
||||
}
|
||||
|
||||
if Task.isCancelled {
|
||||
return completion(Self.hashCancelled)
|
||||
}
|
||||
|
||||
await withTaskGroup(of: HashResult?.self) { taskGroup in
|
||||
var results = [HashResult]()
|
||||
results.reserveCapacity(assets.count)
|
||||
for asset in assets {
|
||||
if Task.isCancelled {
|
||||
return completion(Self.hashCancelled)
|
||||
}
|
||||
taskGroup.addTask {
|
||||
guard let self = self else { return nil }
|
||||
return await self.hashAsset(asset, allowNetworkAccess: allowNetworkAccess)
|
||||
}
|
||||
}
|
||||
|
||||
for await result in taskGroup {
|
||||
guard let result = result else {
|
||||
return completion(Self.hashCancelled)
|
||||
}
|
||||
results.append(result)
|
||||
}
|
||||
|
||||
for missing in missingAssetIds {
|
||||
results.append(HashResult(assetId: missing, error: "Asset not found in library", hash: nil))
|
||||
}
|
||||
|
||||
completion(.success(results))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancelHashing() {
|
||||
hashTask?.cancel()
|
||||
hashTask = nil
|
||||
}
|
||||
|
||||
private func hashAsset(_ asset: PHAsset, allowNetworkAccess: Bool) async -> HashResult? {
|
||||
class RequestRef {
|
||||
var id: PHAssetResourceDataRequestID?
|
||||
}
|
||||
let requestRef = RequestRef()
|
||||
return await withTaskCancellationHandler(operation: {
|
||||
if Task.isCancelled {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let resource = asset.getResource() else {
|
||||
return HashResult(assetId: asset.localIdentifier, error: "Cannot get asset resource", hash: nil)
|
||||
}
|
||||
|
||||
if Task.isCancelled {
|
||||
return nil
|
||||
}
|
||||
|
||||
let options = PHAssetResourceRequestOptions()
|
||||
options.isNetworkAccessAllowed = allowNetworkAccess
|
||||
|
||||
return await withCheckedContinuation { continuation in
|
||||
var hasher = Insecure.SHA1()
|
||||
|
||||
requestRef.id = PHAssetResourceManager.default().requestData(
|
||||
for: resource,
|
||||
options: options,
|
||||
dataReceivedHandler: { data in
|
||||
hasher.update(data: data)
|
||||
},
|
||||
completionHandler: { error in
|
||||
let result: HashResult? = switch (error) {
|
||||
case let e as PHPhotosError where e.code == .userCancelled: nil
|
||||
case let .some(e): HashResult(
|
||||
assetId: asset.localIdentifier,
|
||||
error: "Failed to hash asset: \(e.localizedDescription)",
|
||||
hash: nil
|
||||
)
|
||||
case .none:
|
||||
HashResult(
|
||||
assetId: asset.localIdentifier,
|
||||
error: nil,
|
||||
hash: Data(hasher.finalize()).base64EncodedString()
|
||||
)
|
||||
}
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
)
|
||||
}
|
||||
}, onCancel: {
|
||||
guard let requestId = requestRef.id else { return }
|
||||
PHAssetResourceManager.default().cancelDataRequest(requestId)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import Photos
|
||||
|
||||
extension PHAsset {
|
||||
func toPlatformAsset() -> PlatformAsset {
|
||||
return PlatformAsset(
|
||||
id: localIdentifier,
|
||||
name: title,
|
||||
type: Int64(mediaType.rawValue),
|
||||
createdAt: creationDate.map { Int64($0.timeIntervalSince1970) },
|
||||
updatedAt: modificationDate.map { Int64($0.timeIntervalSince1970) },
|
||||
width: Int64(pixelWidth),
|
||||
height: Int64(pixelHeight),
|
||||
durationInSeconds: Int64(duration),
|
||||
orientation: 0,
|
||||
isFavorite: isFavorite
|
||||
)
|
||||
}
|
||||
|
||||
var title: String {
|
||||
return filename ?? originalFilename ?? "<unknown>"
|
||||
}
|
||||
|
||||
var filename: String? {
|
||||
return value(forKey: "filename") as? String
|
||||
}
|
||||
|
||||
// This method is expected to be slow as it goes through the asset resources to fetch the originalFilename
|
||||
var originalFilename: String? {
|
||||
return getResource()?.originalFilename
|
||||
}
|
||||
|
||||
func getResource() -> PHAssetResource? {
|
||||
let resources = PHAssetResource.assetResources(for: self)
|
||||
|
||||
let filteredResources = resources.filter { $0.isMediaResource && isValidResourceType($0.type) }
|
||||
|
||||
guard !filteredResources.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if filteredResources.count == 1 {
|
||||
return filteredResources.first
|
||||
}
|
||||
|
||||
if let currentResource = filteredResources.first(where: { $0.isCurrent }) {
|
||||
return currentResource
|
||||
}
|
||||
|
||||
if let fullSizeResource = filteredResources.first(where: { isFullSizeResourceType($0.type) }) {
|
||||
return fullSizeResource
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func isValidResourceType(_ type: PHAssetResourceType) -> Bool {
|
||||
switch mediaType {
|
||||
case .image:
|
||||
return [.photo, .alternatePhoto, .fullSizePhoto].contains(type)
|
||||
case .video:
|
||||
return [.video, .fullSizeVideo, .fullSizePairedVideo].contains(type)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func isFullSizeResourceType(_ type: PHAssetResourceType) -> Bool {
|
||||
switch mediaType {
|
||||
case .image:
|
||||
return type == .fullSizePhoto
|
||||
case .video:
|
||||
return type == .fullSizeVideo
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
|
||||
import Photos
|
||||
|
||||
extension PHAssetResource {
|
||||
var isCurrent: Bool {
|
||||
return value(forKey: "isCurrent") as? Bool ?? false
|
||||
}
|
||||
|
||||
var isMediaResource: Bool {
|
||||
var isMedia = type != .adjustmentData
|
||||
if #available(iOS 17, *) {
|
||||
isMedia = isMedia && type != .photoProxy
|
||||
}
|
||||
return isMedia
|
||||
}
|
||||
}
|
||||
@@ -104,10 +104,13 @@ struct Album: Codable, Equatable {
|
||||
// MARK: API
|
||||
|
||||
class ImmichAPI {
|
||||
typealias CustomHeaders = [String:String]
|
||||
struct ServerConfig {
|
||||
let serverEndpoint: String
|
||||
let sessionKey: String
|
||||
let customHeaders: CustomHeaders
|
||||
}
|
||||
|
||||
let serverConfig: ServerConfig
|
||||
|
||||
init() async throws {
|
||||
@@ -122,10 +125,20 @@ class ImmichAPI {
|
||||
if serverURL == "" || sessionKey == "" {
|
||||
throw WidgetError.noLogin
|
||||
}
|
||||
|
||||
// custom headers come in the form of KV pairs in JSON
|
||||
var customHeadersJSON = (defaults.string(forKey: "widget_custom_headers") ?? "")
|
||||
var customHeaders: CustomHeaders = [:]
|
||||
|
||||
if customHeadersJSON != "",
|
||||
let parsedHeaders = try? JSONDecoder().decode(CustomHeaders.self, from: customHeadersJSON.data(using: .utf8)!) {
|
||||
customHeaders = parsedHeaders
|
||||
}
|
||||
|
||||
serverConfig = ServerConfig(
|
||||
serverEndpoint: serverURL,
|
||||
sessionKey: sessionKey
|
||||
sessionKey: sessionKey,
|
||||
customHeaders: customHeaders
|
||||
)
|
||||
}
|
||||
|
||||
@@ -155,6 +168,12 @@ class ImmichAPI {
|
||||
|
||||
return components?.url
|
||||
}
|
||||
|
||||
func applyCustomHeaders(for request: inout URLRequest) {
|
||||
for (header, value) in serverConfig.customHeaders {
|
||||
request.addValue(value, forHTTPHeaderField: header)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchSearchResults(with filters: SearchFilter = Album.NONE.filter)
|
||||
async throws
|
||||
@@ -174,7 +193,8 @@ class ImmichAPI {
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = try JSONEncoder().encode(filters)
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
applyCustomHeaders(for: &request)
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
|
||||
// decode data
|
||||
@@ -196,6 +216,7 @@ class ImmichAPI {
|
||||
|
||||
var request = URLRequest(url: searchURL)
|
||||
request.httpMethod = "GET"
|
||||
applyCustomHeaders(for: &request)
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
|
||||
@@ -254,7 +275,8 @@ class ImmichAPI {
|
||||
|
||||
var request = URLRequest(url: searchURL)
|
||||
request.httpMethod = "GET"
|
||||
|
||||
applyCustomHeaders(for: &request)
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
|
||||
// decode data
|
||||
|
||||
@@ -22,7 +22,7 @@ platform :ios do
|
||||
path: "./Runner.xcodeproj",
|
||||
)
|
||||
increment_version_number(
|
||||
version_number: "1.135.3"
|
||||
version_number: "2.0.1"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum ImmichColorPreset {
|
||||
indigo,
|
||||
deepPurple,
|
||||
pink,
|
||||
red,
|
||||
orange,
|
||||
yellow,
|
||||
lime,
|
||||
green,
|
||||
cyan,
|
||||
slateGray
|
||||
}
|
||||
enum ImmichColorPreset { indigo, deepPurple, pink, red, orange, yellow, lime, green, cyan, slateGray }
|
||||
|
||||
const ImmichColorPreset defaultColorPreset = ImmichColorPreset.indigo;
|
||||
const String defaultColorPresetName = "indigo";
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import 'dart:io';
|
||||
|
||||
const int noDbId = -9223372036854775808; // from Isar
|
||||
const double downloadCompleted = -1;
|
||||
const double downloadFailed = -2;
|
||||
|
||||
// Number of log entries to retain on app start
|
||||
const int kLogTruncateLimit = 250;
|
||||
const int kLogTruncateLimit = 2000;
|
||||
|
||||
// Sync
|
||||
const int kSyncEventBatchSize = 5000;
|
||||
const int kFetchLocalAssetsBatchSize = 40000;
|
||||
|
||||
// Hash batch limits
|
||||
const int kBatchHashFileLimit = 128;
|
||||
final int kBatchHashFileLimit = Platform.isIOS ? 32 : 512;
|
||||
const int kBatchHashSizeLimit = 1024 * 1024 * 1024; // 1GB
|
||||
|
||||
// Secure storage keys
|
||||
@@ -20,16 +22,20 @@ const String kSecuredPinCode = "secured_pin_code";
|
||||
const String kManualUploadGroup = 'manual_upload_group';
|
||||
const String kBackupGroup = 'backup_group';
|
||||
const String kBackupLivePhotoGroup = 'backup_live_photo_group';
|
||||
const String kDownloadGroupImage = 'group_image';
|
||||
const String kDownloadGroupVideo = 'group_video';
|
||||
const String kDownloadGroupLivePhoto = 'group_livephoto';
|
||||
|
||||
// Timeline constants
|
||||
const int kTimelineNoneSegmentSize = 120;
|
||||
const int kTimelineAssetLoadBatchSize = 256;
|
||||
const int kTimelineAssetLoadBatchSize = 1024;
|
||||
const int kTimelineAssetLoadOppositeSize = 64;
|
||||
|
||||
// Widget keys
|
||||
const String appShareGroupId = "group.app.immich.share";
|
||||
const String kWidgetAuthToken = "widget_auth_token";
|
||||
const String kWidgetServerEndpoint = "widget_server_url";
|
||||
const String appShareGroupId = "group.app.immich.share";
|
||||
const String kWidgetCustomHeaders = "widget_custom_headers";
|
||||
|
||||
// add widget identifiers here for new widgets
|
||||
// these are used to force a widget refresh
|
||||
@@ -41,3 +47,5 @@ const List<(String, String)> kWidgetNames = [
|
||||
|
||||
const double kUploadStatusFailed = -1.0;
|
||||
const double kUploadStatusCanceled = -2.0;
|
||||
|
||||
const int kMinMonthsToEnableScrubberSnap = 12;
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
enum SortOrder {
|
||||
asc,
|
||||
desc,
|
||||
}
|
||||
enum SortOrder { asc, desc }
|
||||
|
||||
enum TextSearchType {
|
||||
context,
|
||||
filename,
|
||||
description,
|
||||
}
|
||||
enum TextSearchType { context, filename, description }
|
||||
|
||||
enum AssetVisibilityEnum { timeline, hidden, archive, locked }
|
||||
|
||||
|
||||
@@ -2,511 +2,49 @@ import 'package:flutter/material.dart';
|
||||
|
||||
const List<ColorFilter> filters = [
|
||||
//Original
|
||||
ColorFilter.matrix([
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0]),
|
||||
//Vintage
|
||||
ColorFilter.matrix([
|
||||
0.8,
|
||||
0.1,
|
||||
0.1,
|
||||
0,
|
||||
20,
|
||||
0.1,
|
||||
0.8,
|
||||
0.1,
|
||||
0,
|
||||
20,
|
||||
0.1,
|
||||
0.1,
|
||||
0.8,
|
||||
0,
|
||||
20,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([0.8, 0.1, 0.1, 0, 20, 0.1, 0.8, 0.1, 0, 20, 0.1, 0.1, 0.8, 0, 20, 0, 0, 0, 1, 0]),
|
||||
//Mood
|
||||
ColorFilter.matrix([
|
||||
1.2,
|
||||
0.1,
|
||||
0.1,
|
||||
0,
|
||||
10,
|
||||
0.1,
|
||||
1,
|
||||
0.1,
|
||||
0,
|
||||
10,
|
||||
0.1,
|
||||
0.1,
|
||||
1,
|
||||
0,
|
||||
10,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([1.2, 0.1, 0.1, 0, 10, 0.1, 1, 0.1, 0, 10, 0.1, 0.1, 1, 0, 10, 0, 0, 0, 1, 0]),
|
||||
//Crisp
|
||||
ColorFilter.matrix([
|
||||
1.2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1.2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1.2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([1.2, 0, 0, 0, 0, 0, 1.2, 0, 0, 0, 0, 0, 1.2, 0, 0, 0, 0, 0, 1, 0]),
|
||||
//Cool
|
||||
ColorFilter.matrix([
|
||||
0.9,
|
||||
0,
|
||||
0.2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0.1,
|
||||
0,
|
||||
0,
|
||||
0.1,
|
||||
0,
|
||||
1.2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([0.9, 0, 0.2, 0, 0, 0, 1, 0.1, 0, 0, 0.1, 0, 1.2, 0, 0, 0, 0, 0, 1, 0]),
|
||||
//Blush
|
||||
ColorFilter.matrix([
|
||||
1.1,
|
||||
0.1,
|
||||
0.1,
|
||||
0,
|
||||
10,
|
||||
0.1,
|
||||
1,
|
||||
0.1,
|
||||
0,
|
||||
10,
|
||||
0.1,
|
||||
0.1,
|
||||
1,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([1.1, 0.1, 0.1, 0, 10, 0.1, 1, 0.1, 0, 10, 0.1, 0.1, 1, 0, 5, 0, 0, 0, 1, 0]),
|
||||
//Sunkissed
|
||||
ColorFilter.matrix([
|
||||
1.3,
|
||||
0,
|
||||
0.1,
|
||||
0,
|
||||
15,
|
||||
0,
|
||||
1.1,
|
||||
0.1,
|
||||
0,
|
||||
10,
|
||||
0,
|
||||
0,
|
||||
0.9,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([1.3, 0, 0.1, 0, 15, 0, 1.1, 0.1, 0, 10, 0, 0, 0.9, 0, 5, 0, 0, 0, 1, 0]),
|
||||
//Fresh
|
||||
ColorFilter.matrix([
|
||||
1.2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
20,
|
||||
0,
|
||||
1.2,
|
||||
0,
|
||||
0,
|
||||
20,
|
||||
0,
|
||||
0,
|
||||
1.1,
|
||||
0,
|
||||
20,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([1.2, 0, 0, 0, 20, 0, 1.2, 0, 0, 20, 0, 0, 1.1, 0, 20, 0, 0, 0, 1, 0]),
|
||||
//Classic
|
||||
ColorFilter.matrix([
|
||||
1.1,
|
||||
0,
|
||||
-0.1,
|
||||
0,
|
||||
10,
|
||||
-0.1,
|
||||
1.1,
|
||||
0.1,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
-0.1,
|
||||
1.1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([1.1, 0, -0.1, 0, 10, -0.1, 1.1, 0.1, 0, 5, 0, -0.1, 1.1, 0, 0, 0, 0, 0, 1, 0]),
|
||||
//Lomo-ish
|
||||
ColorFilter.matrix([
|
||||
1.5,
|
||||
0,
|
||||
0.1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1.45,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0.1,
|
||||
0,
|
||||
1.3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([1.5, 0, 0.1, 0, 0, 0, 1.45, 0, 0, 0, 0.1, 0, 1.3, 0, 0, 0, 0, 0, 1, 0]),
|
||||
//Nashville
|
||||
ColorFilter.matrix([
|
||||
1.2,
|
||||
0.15,
|
||||
-0.15,
|
||||
0,
|
||||
15,
|
||||
0.1,
|
||||
1.1,
|
||||
0.1,
|
||||
0,
|
||||
10,
|
||||
-0.05,
|
||||
0.2,
|
||||
1.25,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([1.2, 0.15, -0.15, 0, 15, 0.1, 1.1, 0.1, 0, 10, -0.05, 0.2, 1.25, 0, 5, 0, 0, 0, 1, 0]),
|
||||
//Valencia
|
||||
ColorFilter.matrix([
|
||||
1.15,
|
||||
0.1,
|
||||
0.1,
|
||||
0,
|
||||
20,
|
||||
0.1,
|
||||
1.1,
|
||||
0,
|
||||
0,
|
||||
10,
|
||||
0.1,
|
||||
0.1,
|
||||
1.2,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([1.15, 0.1, 0.1, 0, 20, 0.1, 1.1, 0, 0, 10, 0.1, 0.1, 1.2, 0, 5, 0, 0, 0, 1, 0]),
|
||||
//Clarendon
|
||||
ColorFilter.matrix([
|
||||
1.2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
10,
|
||||
0,
|
||||
1.25,
|
||||
0,
|
||||
0,
|
||||
10,
|
||||
0,
|
||||
0,
|
||||
1.3,
|
||||
0,
|
||||
10,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([1.2, 0, 0, 0, 10, 0, 1.25, 0, 0, 10, 0, 0, 1.3, 0, 10, 0, 0, 0, 1, 0]),
|
||||
//Moon
|
||||
ColorFilter.matrix([
|
||||
0.33,
|
||||
0.33,
|
||||
0.33,
|
||||
0,
|
||||
0,
|
||||
0.33,
|
||||
0.33,
|
||||
0.33,
|
||||
0,
|
||||
0,
|
||||
0.33,
|
||||
0.33,
|
||||
0.33,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([0.33, 0.33, 0.33, 0, 0, 0.33, 0.33, 0.33, 0, 0, 0.33, 0.33, 0.33, 0, 0, 0, 0, 0, 1, 0]),
|
||||
//Willow
|
||||
ColorFilter.matrix([
|
||||
0.5,
|
||||
0.5,
|
||||
0.5,
|
||||
0,
|
||||
20,
|
||||
0.5,
|
||||
0.5,
|
||||
0.5,
|
||||
0,
|
||||
20,
|
||||
0.5,
|
||||
0.5,
|
||||
0.5,
|
||||
0,
|
||||
20,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([0.5, 0.5, 0.5, 0, 20, 0.5, 0.5, 0.5, 0, 20, 0.5, 0.5, 0.5, 0, 20, 0, 0, 0, 1, 0]),
|
||||
//Kodak
|
||||
ColorFilter.matrix([
|
||||
1.3,
|
||||
0.1,
|
||||
-0.1,
|
||||
0,
|
||||
10,
|
||||
0,
|
||||
1.25,
|
||||
0.1,
|
||||
0,
|
||||
10,
|
||||
0,
|
||||
-0.1,
|
||||
1.1,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([1.3, 0.1, -0.1, 0, 10, 0, 1.25, 0.1, 0, 10, 0, -0.1, 1.1, 0, 5, 0, 0, 0, 1, 0]),
|
||||
//Frost
|
||||
ColorFilter.matrix([
|
||||
0.8,
|
||||
0.2,
|
||||
0.1,
|
||||
0,
|
||||
0,
|
||||
0.2,
|
||||
1.1,
|
||||
0.1,
|
||||
0,
|
||||
0,
|
||||
0.1,
|
||||
0.1,
|
||||
1.2,
|
||||
0,
|
||||
10,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([0.8, 0.2, 0.1, 0, 0, 0.2, 1.1, 0.1, 0, 0, 0.1, 0.1, 1.2, 0, 10, 0, 0, 0, 1, 0]),
|
||||
//Night Vision
|
||||
ColorFilter.matrix([
|
||||
0.1,
|
||||
0.95,
|
||||
0.2,
|
||||
0,
|
||||
0,
|
||||
0.1,
|
||||
1.5,
|
||||
0.1,
|
||||
0,
|
||||
0,
|
||||
0.2,
|
||||
0.7,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([0.1, 0.95, 0.2, 0, 0, 0.1, 1.5, 0.1, 0, 0, 0.2, 0.7, 0, 0, 0, 0, 0, 0, 1, 0]),
|
||||
//Sunset
|
||||
ColorFilter.matrix([
|
||||
1.5,
|
||||
0.2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0.1,
|
||||
0.9,
|
||||
0.1,
|
||||
0,
|
||||
0,
|
||||
-0.1,
|
||||
-0.2,
|
||||
1.3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([1.5, 0.2, 0, 0, 0, 0.1, 0.9, 0.1, 0, 0, -0.1, -0.2, 1.3, 0, 0, 0, 0, 0, 1, 0]),
|
||||
//Noir
|
||||
ColorFilter.matrix([
|
||||
1.3,
|
||||
-0.3,
|
||||
0.1,
|
||||
0,
|
||||
0,
|
||||
-0.1,
|
||||
1.2,
|
||||
-0.1,
|
||||
0,
|
||||
0,
|
||||
0.1,
|
||||
-0.2,
|
||||
1.3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([1.3, -0.3, 0.1, 0, 0, -0.1, 1.2, -0.1, 0, 0, 0.1, -0.2, 1.3, 0, 0, 0, 0, 0, 1, 0]),
|
||||
//Dreamy
|
||||
ColorFilter.matrix([
|
||||
1.1,
|
||||
0.1,
|
||||
0.1,
|
||||
0,
|
||||
0,
|
||||
0.1,
|
||||
1.1,
|
||||
0.1,
|
||||
0,
|
||||
0,
|
||||
0.1,
|
||||
0.1,
|
||||
1.1,
|
||||
0,
|
||||
15,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([1.1, 0.1, 0.1, 0, 0, 0.1, 1.1, 0.1, 0, 0, 0.1, 0.1, 1.1, 0, 15, 0, 0, 0, 1, 0]),
|
||||
//Sepia
|
||||
ColorFilter.matrix([
|
||||
0.393,
|
||||
0.769,
|
||||
0.189,
|
||||
0,
|
||||
0,
|
||||
0.349,
|
||||
0.686,
|
||||
0.168,
|
||||
0,
|
||||
0,
|
||||
0.272,
|
||||
0.534,
|
||||
0.131,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([0.393, 0.769, 0.189, 0, 0, 0.349, 0.686, 0.168, 0, 0, 0.272, 0.534, 0.131, 0, 0, 0, 0, 0, 1, 0]),
|
||||
//Radium
|
||||
ColorFilter.matrix([
|
||||
1.438,
|
||||
@@ -554,212 +92,23 @@ const List<ColorFilter> filters = [
|
||||
0,
|
||||
]),
|
||||
//Purple Haze
|
||||
ColorFilter.matrix([
|
||||
1.3,
|
||||
0,
|
||||
1.2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1.1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0.2,
|
||||
0,
|
||||
1.3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([1.3, 0, 1.2, 0, 0, 0, 1.1, 0, 0, 0, 0.2, 0, 1.3, 0, 0, 0, 0, 0, 1, 0]),
|
||||
//Lemonade
|
||||
ColorFilter.matrix([
|
||||
1.2,
|
||||
0.1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1.1,
|
||||
0.2,
|
||||
0,
|
||||
0,
|
||||
0.1,
|
||||
0,
|
||||
0.7,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([1.2, 0.1, 0, 0, 0, 0, 1.1, 0.2, 0, 0, 0.1, 0, 0.7, 0, 0, 0, 0, 0, 1, 0]),
|
||||
//Caramel
|
||||
ColorFilter.matrix([
|
||||
1.6,
|
||||
0.2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0.1,
|
||||
1.3,
|
||||
0.1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0.1,
|
||||
0.9,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([1.6, 0.2, 0, 0, 0, 0.1, 1.3, 0.1, 0, 0, 0, 0.1, 0.9, 0, 0, 0, 0, 0, 1, 0]),
|
||||
//Peachy
|
||||
ColorFilter.matrix([
|
||||
1.3,
|
||||
0.5,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0.2,
|
||||
1.1,
|
||||
0.3,
|
||||
0,
|
||||
0,
|
||||
0.1,
|
||||
0.1,
|
||||
1.2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([1.3, 0.5, 0, 0, 0, 0.2, 1.1, 0.3, 0, 0, 0.1, 0.1, 1.2, 0, 0, 0, 0, 0, 1, 0]),
|
||||
//Neon
|
||||
ColorFilter.matrix([
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([1, 0, 1, 0, 0, 0, 2, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 1, 0]),
|
||||
//Cold Morning
|
||||
ColorFilter.matrix([
|
||||
0.9,
|
||||
0.1,
|
||||
0.2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0.1,
|
||||
0,
|
||||
0,
|
||||
0.1,
|
||||
0,
|
||||
1.2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([0.9, 0.1, 0.2, 0, 0, 0, 1, 0.1, 0, 0, 0.1, 0, 1.2, 0, 0, 0, 0, 0, 1, 0]),
|
||||
//Lush
|
||||
ColorFilter.matrix([
|
||||
0.9,
|
||||
0.2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1.2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1.1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([0.9, 0.2, 0, 0, 0, 0, 1.2, 0, 0, 0, 0, 0, 1.1, 0, 0, 0, 0, 0, 1, 0]),
|
||||
//Urban Neon
|
||||
ColorFilter.matrix([
|
||||
1.1,
|
||||
0,
|
||||
0.3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0.9,
|
||||
0.3,
|
||||
0,
|
||||
0,
|
||||
0.3,
|
||||
0.1,
|
||||
1.2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([1.1, 0, 0.3, 0, 0, 0, 0.9, 0.3, 0, 0, 0.3, 0.1, 1.2, 0, 0, 0, 0, 0, 1, 0]),
|
||||
//Monochrome
|
||||
ColorFilter.matrix([
|
||||
0.6,
|
||||
0.2,
|
||||
0.2,
|
||||
0,
|
||||
0,
|
||||
0.2,
|
||||
0.6,
|
||||
0.2,
|
||||
0,
|
||||
0,
|
||||
0.2,
|
||||
0.2,
|
||||
0.7,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]),
|
||||
ColorFilter.matrix([0.6, 0.2, 0.2, 0, 0, 0.2, 0.6, 0.2, 0, 0, 0.2, 0.2, 0.7, 0, 0, 0, 0, 0, 1, 0]),
|
||||
];
|
||||
|
||||
const List<String> filterNames = [
|
||||
|
||||
@@ -7,10 +7,8 @@ const Map<String, Locale> locales = {
|
||||
'Arabic (ar)': Locale('ar'),
|
||||
'Bulgarian (bg)': Locale('bg'),
|
||||
'Catalan (ca)': Locale('ca'),
|
||||
'Chinese Simplified (zh_CN)':
|
||||
Locale.fromSubtags(languageCode: 'zh', scriptCode: 'SIMPLIFIED'),
|
||||
'Chinese Traditional (zh_TW)':
|
||||
Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'),
|
||||
'Chinese Simplified (zh_CN)': Locale.fromSubtags(languageCode: 'zh', scriptCode: 'SIMPLIFIED'),
|
||||
'Chinese Traditional (zh_TW)': Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'),
|
||||
'Croatian (hr)': Locale('hr'),
|
||||
'Czech (cs)': Locale('cs'),
|
||||
'Danish (da)': Locale('da'),
|
||||
@@ -37,10 +35,8 @@ const Map<String, Locale> locales = {
|
||||
'Portuguese (pt)': Locale('pt'),
|
||||
'Romanian (ro)': Locale('ro'),
|
||||
'Russian (ru)': Locale('ru'),
|
||||
'Serbian Cyrillic (sr_Cyrl)':
|
||||
Locale.fromSubtags(languageCode: 'sr', scriptCode: 'Cyrl'),
|
||||
'Serbian Latin (sr_Latn)':
|
||||
Locale.fromSubtags(languageCode: 'sr', scriptCode: 'Latn'),
|
||||
'Serbian Cyrillic (sr_Cyrl)': Locale.fromSubtags(languageCode: 'sr', scriptCode: 'Cyrl'),
|
||||
'Serbian Latin (sr_Latn)': Locale.fromSubtags(languageCode: 'sr', scriptCode: 'Latn'),
|
||||
'Slovak (sk)': Locale('sk'),
|
||||
'Slovenian (sl)': Locale('sl'),
|
||||
'Spanish (es)': Locale('es'),
|
||||
@@ -55,7 +51,4 @@ const Map<String, Locale> locales = {
|
||||
|
||||
const String translationsPath = 'assets/i18n';
|
||||
|
||||
const List<Locale> localesNotSupportedByOverpass = [
|
||||
Locale('el', 'GR'),
|
||||
Locale('sr', 'Cyrl'),
|
||||
];
|
||||
const List<Locale> localesNotSupportedByOverpass = [Locale('el', 'GR'), Locale('sr', 'Cyrl')];
|
||||
|
||||
@@ -23,6 +23,7 @@ class RemoteAlbum {
|
||||
final AlbumAssetOrder order;
|
||||
final int assetCount;
|
||||
final String ownerName;
|
||||
final bool isShared;
|
||||
|
||||
const RemoteAlbum({
|
||||
required this.id,
|
||||
@@ -36,6 +37,7 @@ class RemoteAlbum {
|
||||
required this.order,
|
||||
required this.assetCount,
|
||||
required this.ownerName,
|
||||
required this.isShared,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -52,6 +54,7 @@ class RemoteAlbum {
|
||||
thumbnailAssetId: ${thumbnailAssetId ?? "<NA>"}
|
||||
assetCount: $assetCount
|
||||
ownerName: $ownerName
|
||||
isShared: $isShared
|
||||
}''';
|
||||
}
|
||||
|
||||
@@ -69,7 +72,8 @@ class RemoteAlbum {
|
||||
isActivityEnabled == other.isActivityEnabled &&
|
||||
order == other.order &&
|
||||
assetCount == other.assetCount &&
|
||||
ownerName == other.ownerName;
|
||||
ownerName == other.ownerName &&
|
||||
isShared == other.isShared;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -84,7 +88,8 @@ class RemoteAlbum {
|
||||
isActivityEnabled.hashCode ^
|
||||
order.hashCode ^
|
||||
assetCount.hashCode ^
|
||||
ownerName.hashCode;
|
||||
ownerName.hashCode ^
|
||||
isShared.hashCode;
|
||||
}
|
||||
|
||||
RemoteAlbum copyWith({
|
||||
@@ -99,6 +104,7 @@ class RemoteAlbum {
|
||||
AlbumAssetOrder? order,
|
||||
int? assetCount,
|
||||
String? ownerName,
|
||||
bool? isShared,
|
||||
}) {
|
||||
return RemoteAlbum(
|
||||
id: id ?? this.id,
|
||||
@@ -112,6 +118,7 @@ class RemoteAlbum {
|
||||
order: order ?? this.order,
|
||||
assetCount: assetCount ?? this.assetCount,
|
||||
ownerName: ownerName ?? this.ownerName,
|
||||
isShared: isShared ?? this.isShared,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ class LocalAlbum {
|
||||
|
||||
final int assetCount;
|
||||
final BackupSelection backupSelection;
|
||||
final String? linkedRemoteAlbumId;
|
||||
|
||||
const LocalAlbum({
|
||||
required this.id,
|
||||
@@ -23,6 +24,7 @@ class LocalAlbum {
|
||||
this.assetCount = 0,
|
||||
this.backupSelection = BackupSelection.none,
|
||||
this.isIosSharedAlbum = false,
|
||||
this.linkedRemoteAlbumId,
|
||||
});
|
||||
|
||||
LocalAlbum copyWith({
|
||||
@@ -32,6 +34,7 @@ class LocalAlbum {
|
||||
int? assetCount,
|
||||
BackupSelection? backupSelection,
|
||||
bool? isIosSharedAlbum,
|
||||
String? linkedRemoteAlbumId,
|
||||
}) {
|
||||
return LocalAlbum(
|
||||
id: id ?? this.id,
|
||||
@@ -40,6 +43,7 @@ class LocalAlbum {
|
||||
assetCount: assetCount ?? this.assetCount,
|
||||
backupSelection: backupSelection ?? this.backupSelection,
|
||||
isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum,
|
||||
linkedRemoteAlbumId: linkedRemoteAlbumId ?? this.linkedRemoteAlbumId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,7 +57,8 @@ class LocalAlbum {
|
||||
other.updatedAt == updatedAt &&
|
||||
other.assetCount == assetCount &&
|
||||
other.backupSelection == backupSelection &&
|
||||
other.isIosSharedAlbum == isIosSharedAlbum;
|
||||
other.isIosSharedAlbum == isIosSharedAlbum &&
|
||||
other.linkedRemoteAlbumId == linkedRemoteAlbumId;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -63,7 +68,8 @@ class LocalAlbum {
|
||||
updatedAt.hashCode ^
|
||||
assetCount.hashCode ^
|
||||
backupSelection.hashCode ^
|
||||
isIosSharedAlbum.hashCode;
|
||||
isIosSharedAlbum.hashCode ^
|
||||
linkedRemoteAlbumId.hashCode;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -75,6 +81,7 @@ updatedAt: $updatedAt,
|
||||
assetCount: $assetCount,
|
||||
backupSelection: $backupSelection,
|
||||
isIosSharedAlbum: $isIosSharedAlbum
|
||||
linkedRemoteAlbumId: $linkedRemoteAlbumId,
|
||||
}''';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,7 @@ enum AssetType {
|
||||
audio,
|
||||
}
|
||||
|
||||
enum AssetState {
|
||||
local,
|
||||
remote,
|
||||
merged,
|
||||
}
|
||||
enum AssetState { local, remote, merged }
|
||||
|
||||
sealed class BaseAsset {
|
||||
final String name;
|
||||
@@ -53,10 +49,8 @@ sealed class BaseAsset {
|
||||
return const Duration();
|
||||
}
|
||||
|
||||
bool get hasRemote =>
|
||||
storage == AssetState.remote || storage == AssetState.merged;
|
||||
bool get hasLocal =>
|
||||
storage == AssetState.local || storage == AssetState.merged;
|
||||
bool get hasRemote => storage == AssetState.remote || storage == AssetState.merged;
|
||||
bool get hasLocal => storage == AssetState.local || storage == AssetState.merged;
|
||||
bool get isLocalOnly => storage == AssetState.local;
|
||||
bool get isRemoteOnly => storage == AssetState.remote;
|
||||
|
||||
|
||||
@@ -22,8 +22,7 @@ class LocalAsset extends BaseAsset {
|
||||
});
|
||||
|
||||
@override
|
||||
AssetState get storage =>
|
||||
remoteId == null ? AssetState.local : AssetState.merged;
|
||||
AssetState get storage => remoteId == null ? AssetState.local : AssetState.merged;
|
||||
|
||||
@override
|
||||
String get heroTag => '${id}_${remoteId ?? checksum}';
|
||||
@@ -54,8 +53,7 @@ class LocalAsset extends BaseAsset {
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
super.hashCode ^ id.hashCode ^ remoteId.hashCode ^ orientation.hashCode;
|
||||
int get hashCode => super.hashCode ^ id.hashCode ^ remoteId.hashCode ^ orientation.hashCode;
|
||||
|
||||
LocalAsset copyWith({
|
||||
String? id,
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
part of 'base_asset.model.dart';
|
||||
|
||||
enum AssetVisibility {
|
||||
timeline,
|
||||
hidden,
|
||||
archive,
|
||||
locked,
|
||||
}
|
||||
enum AssetVisibility { timeline, hidden, archive, locked }
|
||||
|
||||
// Model for an asset stored in the server
|
||||
class RemoteAsset extends BaseAsset {
|
||||
@@ -15,7 +10,6 @@ class RemoteAsset extends BaseAsset {
|
||||
final AssetVisibility visibility;
|
||||
final String ownerId;
|
||||
final String? stackId;
|
||||
final int stackCount;
|
||||
|
||||
const RemoteAsset({
|
||||
required this.id,
|
||||
@@ -34,12 +28,10 @@ class RemoteAsset extends BaseAsset {
|
||||
this.visibility = AssetVisibility.timeline,
|
||||
super.livePhotoVideoId,
|
||||
this.stackId,
|
||||
this.stackCount = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
AssetState get storage =>
|
||||
localId == null ? AssetState.remote : AssetState.merged;
|
||||
AssetState get storage => localId == null ? AssetState.remote : AssetState.merged;
|
||||
|
||||
@override
|
||||
String get heroTag => '${localId ?? checksum}_$id';
|
||||
@@ -61,7 +53,6 @@ class RemoteAsset extends BaseAsset {
|
||||
thumbHash: ${thumbHash ?? "<NA>"},
|
||||
visibility: $visibility,
|
||||
stackId: ${stackId ?? "<NA>"},
|
||||
stackCount: $stackCount,
|
||||
checksum: $checksum,
|
||||
livePhotoVideoId: ${livePhotoVideoId ?? "<NA>"},
|
||||
}''';
|
||||
@@ -77,8 +68,7 @@ class RemoteAsset extends BaseAsset {
|
||||
ownerId == other.ownerId &&
|
||||
thumbHash == other.thumbHash &&
|
||||
visibility == other.visibility &&
|
||||
stackId == other.stackId &&
|
||||
stackCount == other.stackCount;
|
||||
stackId == other.stackId;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -89,8 +79,7 @@ class RemoteAsset extends BaseAsset {
|
||||
localId.hashCode ^
|
||||
thumbHash.hashCode ^
|
||||
visibility.hashCode ^
|
||||
stackId.hashCode ^
|
||||
stackCount.hashCode;
|
||||
stackId.hashCode;
|
||||
|
||||
RemoteAsset copyWith({
|
||||
String? id,
|
||||
@@ -109,7 +98,6 @@ class RemoteAsset extends BaseAsset {
|
||||
AssetVisibility? visibility,
|
||||
String? livePhotoVideoId,
|
||||
String? stackId,
|
||||
int? stackCount,
|
||||
}) {
|
||||
return RemoteAsset(
|
||||
id: id ?? this.id,
|
||||
@@ -128,7 +116,6 @@ class RemoteAsset extends BaseAsset {
|
||||
visibility: visibility ?? this.visibility,
|
||||
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
|
||||
stackId: stackId ?? this.stackId,
|
||||
stackCount: stackCount ?? this.stackCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Model for an asset face stored in the server
|
||||
class AssetFace {
|
||||
final String id;
|
||||
final String assetId;
|
||||
final String? personId;
|
||||
final int imageWidth;
|
||||
final int imageHeight;
|
||||
final int boundingBoxX1;
|
||||
final int boundingBoxY1;
|
||||
final int boundingBoxX2;
|
||||
final int boundingBoxY2;
|
||||
final String sourceType;
|
||||
|
||||
const AssetFace({
|
||||
required this.id,
|
||||
required this.assetId,
|
||||
this.personId,
|
||||
required this.imageWidth,
|
||||
required this.imageHeight,
|
||||
required this.boundingBoxX1,
|
||||
required this.boundingBoxY1,
|
||||
required this.boundingBoxX2,
|
||||
required this.boundingBoxY2,
|
||||
required this.sourceType,
|
||||
});
|
||||
|
||||
AssetFace copyWith({
|
||||
String? id,
|
||||
String? assetId,
|
||||
String? personId,
|
||||
int? imageWidth,
|
||||
int? imageHeight,
|
||||
int? boundingBoxX1,
|
||||
int? boundingBoxY1,
|
||||
int? boundingBoxX2,
|
||||
int? boundingBoxY2,
|
||||
String? sourceType,
|
||||
}) {
|
||||
return AssetFace(
|
||||
id: id ?? this.id,
|
||||
assetId: assetId ?? this.assetId,
|
||||
personId: personId ?? this.personId,
|
||||
imageWidth: imageWidth ?? this.imageWidth,
|
||||
imageHeight: imageHeight ?? this.imageHeight,
|
||||
boundingBoxX1: boundingBoxX1 ?? this.boundingBoxX1,
|
||||
boundingBoxY1: boundingBoxY1 ?? this.boundingBoxY1,
|
||||
boundingBoxX2: boundingBoxX2 ?? this.boundingBoxX2,
|
||||
boundingBoxY2: boundingBoxY2 ?? this.boundingBoxY2,
|
||||
sourceType: sourceType ?? this.sourceType,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''AssetFace {
|
||||
id: $id,
|
||||
assetId: $assetId,
|
||||
personId: ${personId ?? "<NA>"},
|
||||
imageWidth: $imageWidth,
|
||||
imageHeight: $imageHeight,
|
||||
boundingBoxX1: $boundingBoxX1,
|
||||
boundingBoxY1: $boundingBoxY1,
|
||||
boundingBoxX2: $boundingBoxX2,
|
||||
boundingBoxY2: $boundingBoxY2,
|
||||
sourceType: $sourceType,
|
||||
}''';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant AssetFace other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.id == id &&
|
||||
other.assetId == assetId &&
|
||||
other.personId == personId &&
|
||||
other.imageWidth == imageWidth &&
|
||||
other.imageHeight == imageHeight &&
|
||||
other.boundingBoxX1 == boundingBoxX1 &&
|
||||
other.boundingBoxY1 == boundingBoxY1 &&
|
||||
other.boundingBoxX2 == boundingBoxX2 &&
|
||||
other.boundingBoxY2 == boundingBoxY2 &&
|
||||
other.sourceType == sourceType;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return id.hashCode ^
|
||||
assetId.hashCode ^
|
||||
personId.hashCode ^
|
||||
imageWidth.hashCode ^
|
||||
imageHeight.hashCode ^
|
||||
boundingBoxX1.hashCode ^
|
||||
boundingBoxY1.hashCode ^
|
||||
boundingBoxX2.hashCode ^
|
||||
boundingBoxY2.hashCode ^
|
||||
sourceType.hashCode;
|
||||
}
|
||||
}
|
||||
@@ -5,19 +5,13 @@ class DeviceAsset {
|
||||
final Uint8List hash;
|
||||
final DateTime modifiedTime;
|
||||
|
||||
const DeviceAsset({
|
||||
required this.assetId,
|
||||
required this.hash,
|
||||
required this.modifiedTime,
|
||||
});
|
||||
const DeviceAsset({required this.assetId, required this.hash, required this.modifiedTime});
|
||||
|
||||
@override
|
||||
bool operator ==(covariant DeviceAsset other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.assetId == assetId &&
|
||||
other.hash == hash &&
|
||||
other.modifiedTime == modifiedTime;
|
||||
return other.assetId == assetId && other.hash == hash && other.modifiedTime == modifiedTime;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -30,11 +24,7 @@ class DeviceAsset {
|
||||
return 'DeviceAsset(assetId: $assetId, hash: $hash, modifiedTime: $modifiedTime)';
|
||||
}
|
||||
|
||||
DeviceAsset copyWith({
|
||||
String? assetId,
|
||||
Uint8List? hash,
|
||||
DateTime? modifiedTime,
|
||||
}) {
|
||||
DeviceAsset copyWith({String? assetId, Uint8List? hash, DateTime? modifiedTime}) {
|
||||
return DeviceAsset(
|
||||
assetId: assetId ?? this.assetId,
|
||||
hash: hash ?? this.hash,
|
||||
|
||||
@@ -25,8 +25,7 @@ class ExifInfo {
|
||||
final int? iso;
|
||||
final double? exposureSeconds;
|
||||
|
||||
bool get hasCoordinates =>
|
||||
latitude != null && longitude != null && latitude != 0 && longitude != 0;
|
||||
bool get hasCoordinates => latitude != null && longitude != null && latitude != 0 && longitude != 0;
|
||||
|
||||
String get exposureTime {
|
||||
if (exposureSeconds == null) {
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
/// Log levels according to dart logging [Level]
|
||||
enum LogLevel {
|
||||
all,
|
||||
finest,
|
||||
finer,
|
||||
fine,
|
||||
config,
|
||||
info,
|
||||
warning,
|
||||
severe,
|
||||
shout,
|
||||
off,
|
||||
}
|
||||
enum LogLevel { all, finest, finer, fine, config, info, warning, severe, shout, off }
|
||||
|
||||
class LogMessage {
|
||||
final String message;
|
||||
@@ -43,12 +32,7 @@ class LogMessage {
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return message.hashCode ^
|
||||
level.hashCode ^
|
||||
createdAt.hashCode ^
|
||||
logger.hashCode ^
|
||||
error.hashCode ^
|
||||
stack.hashCode;
|
||||
return message.hashCode ^ level.hashCode ^ createdAt.hashCode ^ logger.hashCode ^ error.hashCode ^ stack.hashCode;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class Marker {
|
||||
final LatLng location;
|
||||
final String assetId;
|
||||
|
||||
const Marker({required this.location, required this.assetId});
|
||||
|
||||
@override
|
||||
bool operator ==(covariant Marker other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.location == location && other.assetId == assetId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => location.hashCode ^ assetId.hashCode;
|
||||
}
|
||||
@@ -13,34 +13,23 @@ enum MemoryTypeEnum {
|
||||
class MemoryData {
|
||||
final int year;
|
||||
|
||||
const MemoryData({
|
||||
required this.year,
|
||||
});
|
||||
const MemoryData({required this.year});
|
||||
|
||||
MemoryData copyWith({
|
||||
int? year,
|
||||
}) {
|
||||
return MemoryData(
|
||||
year: year ?? this.year,
|
||||
);
|
||||
MemoryData copyWith({int? year}) {
|
||||
return MemoryData(year: year ?? this.year);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return <String, dynamic>{
|
||||
'year': year,
|
||||
};
|
||||
return <String, dynamic>{'year': year};
|
||||
}
|
||||
|
||||
factory MemoryData.fromMap(Map<String, dynamic> map) {
|
||||
return MemoryData(
|
||||
year: map['year'] as int,
|
||||
);
|
||||
return MemoryData(year: map['year'] as int);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory MemoryData.fromJson(String source) =>
|
||||
MemoryData.fromMap(json.decode(source) as Map<String, dynamic>);
|
||||
factory MemoryData.fromJson(String source) => MemoryData.fromMap(json.decode(source) as Map<String, dynamic>);
|
||||
|
||||
@override
|
||||
String toString() => 'MemoryData(year: $year)';
|
||||
|
||||
@@ -55,22 +55,17 @@ class PersonDto {
|
||||
factory PersonDto.fromMap(Map<String, dynamic> map) {
|
||||
return PersonDto(
|
||||
id: map['id'] as String,
|
||||
birthDate: map['birthDate'] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(map['birthDate'] as int)
|
||||
: null,
|
||||
birthDate: map['birthDate'] != null ? DateTime.fromMillisecondsSinceEpoch(map['birthDate'] as int) : null,
|
||||
isHidden: map['isHidden'] as bool,
|
||||
name: map['name'] as String,
|
||||
thumbnailPath: map['thumbnailPath'] as String,
|
||||
updatedAt: map['updatedAt'] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(map['updatedAt'] as int)
|
||||
: null,
|
||||
updatedAt: map['updatedAt'] != null ? DateTime.fromMillisecondsSinceEpoch(map['updatedAt'] as int) : null,
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory PersonDto.fromJson(String source) =>
|
||||
PersonDto.fromMap(json.decode(source) as Map<String, dynamic>);
|
||||
factory PersonDto.fromJson(String source) => PersonDto.fromMap(json.decode(source) as Map<String, dynamic>);
|
||||
|
||||
@override
|
||||
bool operator ==(covariant PersonDto other) {
|
||||
@@ -96,54 +91,50 @@ class PersonDto {
|
||||
}
|
||||
|
||||
// Model for a person stored in the server
|
||||
class Person {
|
||||
class DriftPerson {
|
||||
final String id;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final String ownerId;
|
||||
final String name;
|
||||
final String? faceAssetId;
|
||||
final String thumbnailPath;
|
||||
final bool isFavorite;
|
||||
final bool isHidden;
|
||||
final String? color;
|
||||
final DateTime? birthDate;
|
||||
|
||||
const Person({
|
||||
const DriftPerson({
|
||||
required this.id,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.ownerId,
|
||||
required this.name,
|
||||
this.faceAssetId,
|
||||
required this.thumbnailPath,
|
||||
required this.isFavorite,
|
||||
required this.isHidden,
|
||||
required this.color,
|
||||
this.birthDate,
|
||||
});
|
||||
|
||||
Person copyWith({
|
||||
DriftPerson copyWith({
|
||||
String? id,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
String? ownerId,
|
||||
String? name,
|
||||
String? faceAssetId,
|
||||
String? thumbnailPath,
|
||||
bool? isFavorite,
|
||||
bool? isHidden,
|
||||
String? color,
|
||||
DateTime? birthDate,
|
||||
}) {
|
||||
return Person(
|
||||
return DriftPerson(
|
||||
id: id ?? this.id,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
ownerId: ownerId ?? this.ownerId,
|
||||
name: name ?? this.name,
|
||||
faceAssetId: faceAssetId ?? this.faceAssetId,
|
||||
thumbnailPath: thumbnailPath ?? this.thumbnailPath,
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
isHidden: isHidden ?? this.isHidden,
|
||||
color: color ?? this.color,
|
||||
@@ -160,7 +151,6 @@ class Person {
|
||||
ownerId: $ownerId,
|
||||
name: $name,
|
||||
faceAssetId: ${faceAssetId ?? "<NA>"},
|
||||
thumbnailPath: $thumbnailPath,
|
||||
isFavorite: $isFavorite,
|
||||
isHidden: $isHidden,
|
||||
color: ${color ?? "<NA>"},
|
||||
@@ -169,7 +159,7 @@ class Person {
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant Person other) {
|
||||
bool operator ==(covariant DriftPerson other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.id == id &&
|
||||
@@ -178,7 +168,6 @@ class Person {
|
||||
other.ownerId == ownerId &&
|
||||
other.name == name &&
|
||||
other.faceAssetId == faceAssetId &&
|
||||
other.thumbnailPath == thumbnailPath &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.isHidden == isHidden &&
|
||||
other.color == color &&
|
||||
@@ -193,7 +182,6 @@ class Person {
|
||||
ownerId.hashCode ^
|
||||
name.hashCode ^
|
||||
faceAssetId.hashCode ^
|
||||
thumbnailPath.hashCode ^
|
||||
isFavorite.hashCode ^
|
||||
isHidden.hashCode ^
|
||||
color.hashCode ^
|
||||
|
||||
@@ -3,36 +3,30 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
|
||||
class SearchResult {
|
||||
final List<BaseAsset> assets;
|
||||
final double scrollOffset;
|
||||
final int? nextPage;
|
||||
|
||||
const SearchResult({
|
||||
required this.assets,
|
||||
this.nextPage,
|
||||
});
|
||||
const SearchResult({required this.assets, this.scrollOffset = 0.0, this.nextPage});
|
||||
|
||||
int get totalAssets => assets.length;
|
||||
|
||||
SearchResult copyWith({
|
||||
List<BaseAsset>? assets,
|
||||
int? nextPage,
|
||||
}) {
|
||||
SearchResult copyWith({List<BaseAsset>? assets, int? nextPage, double? scrollOffset}) {
|
||||
return SearchResult(
|
||||
assets: assets ?? this.assets,
|
||||
nextPage: nextPage ?? this.nextPage,
|
||||
scrollOffset: scrollOffset ?? this.scrollOffset,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'SearchResult(assets: $assets, nextPage: $nextPage)';
|
||||
String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage, scrollOffset: $scrollOffset)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant SearchResult other) {
|
||||
if (identical(this, other)) return true;
|
||||
final listEquals = const DeepCollectionEquality().equals;
|
||||
|
||||
return listEquals(other.assets, assets) && other.nextPage == nextPage;
|
||||
return listEquals(other.assets, assets) && other.nextPage == nextPage && other.scrollOffset == scrollOffset;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => assets.hashCode ^ nextPage.hashCode;
|
||||
int get hashCode => assets.hashCode ^ nextPage.hashCode ^ scrollOffset.hashCode;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ enum Setting<T> {
|
||||
loadOriginalVideo<bool>(StoreKey.loadOriginalVideo, false),
|
||||
preferRemoteImage<bool>(StoreKey.preferRemoteImage, false),
|
||||
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, false),
|
||||
;
|
||||
enableBackup<bool>(StoreKey.enableBackup, false);
|
||||
|
||||
const Setting(this.storeKey, this.defaultValue);
|
||||
|
||||
|
||||
@@ -14,13 +14,7 @@ class Stack {
|
||||
required this.primaryAssetId,
|
||||
});
|
||||
|
||||
Stack copyWith({
|
||||
String? id,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
String? ownerId,
|
||||
String? primaryAssetId,
|
||||
}) {
|
||||
Stack copyWith({String? id, DateTime? createdAt, DateTime? updatedAt, String? ownerId, String? primaryAssetId}) {
|
||||
return Stack(
|
||||
id: id ?? this.id,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
@@ -54,11 +48,7 @@ class Stack {
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return id.hashCode ^
|
||||
createdAt.hashCode ^
|
||||
updatedAt.hashCode ^
|
||||
ownerId.hashCode ^
|
||||
primaryAssetId.hashCode;
|
||||
return id.hashCode ^ createdAt.hashCode ^ updatedAt.hashCode ^ ownerId.hashCode ^ primaryAssetId.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,19 +57,13 @@ class StackResponse {
|
||||
final String primaryAssetId;
|
||||
final List<String> assetIds;
|
||||
|
||||
const StackResponse({
|
||||
required this.id,
|
||||
required this.primaryAssetId,
|
||||
required this.assetIds,
|
||||
});
|
||||
const StackResponse({required this.id, required this.primaryAssetId, required this.assetIds});
|
||||
|
||||
@override
|
||||
bool operator ==(covariant StackResponse other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.id == id &&
|
||||
other.primaryAssetId == primaryAssetId &&
|
||||
other.assetIds == assetIds;
|
||||
return other.id == id && other.primaryAssetId == primaryAssetId && other.assetIds == assetIds;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -67,11 +67,19 @@ enum StoreKey<T> {
|
||||
loadOriginalVideo<bool>._(136),
|
||||
manageLocalMediaAndroid<bool>._(137),
|
||||
|
||||
// Read-only Mode settings
|
||||
readonlyModeEnabled<bool>._(138),
|
||||
|
||||
// Experimental stuff
|
||||
photoManagerCustomFilter<bool>._(1000),
|
||||
betaPromptShown<bool>._(1001),
|
||||
betaTimeline<bool>._(1002),
|
||||
enableBackup<bool>._(1003);
|
||||
enableBackup<bool>._(1003),
|
||||
useWifiForUploadVideos<bool>._(1004),
|
||||
useWifiForUploadPhotos<bool>._(1005),
|
||||
needBetaMigration<bool>._(1006),
|
||||
// TODO: Remove this after patching open-api
|
||||
shouldResetSync<bool>._(1007);
|
||||
|
||||
const StoreKey._(this.id);
|
||||
final int id;
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
|
||||
enum GroupAssetsBy {
|
||||
day,
|
||||
month,
|
||||
none;
|
||||
}
|
||||
enum GroupAssetsBy { day, month, auto, none }
|
||||
|
||||
enum HeaderType {
|
||||
none,
|
||||
month,
|
||||
day,
|
||||
monthAndDay;
|
||||
}
|
||||
enum HeaderType { none, month, day, monthAndDay }
|
||||
|
||||
class Bucket {
|
||||
final int assetCount;
|
||||
@@ -44,3 +35,13 @@ class TimeBucket extends Bucket {
|
||||
class TimelineReloadEvent extends Event {
|
||||
const TimelineReloadEvent();
|
||||
}
|
||||
|
||||
class ScrollToTopEvent extends Event {
|
||||
const ScrollToTopEvent();
|
||||
}
|
||||
|
||||
class ScrollToDateEvent extends Event {
|
||||
final DateTime date;
|
||||
|
||||
const ScrollToDateEvent(this.date);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,36 @@
|
||||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
import 'dart:convert';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:immich_mobile/domain/models/user_metadata.model.dart';
|
||||
enum AvatarColor {
|
||||
// do not change this order or reuse indices for other purposes, adding is OK
|
||||
primary("primary"),
|
||||
pink("pink"),
|
||||
red("red"),
|
||||
yellow("yellow"),
|
||||
blue("blue"),
|
||||
green("green"),
|
||||
purple("purple"),
|
||||
orange("orange"),
|
||||
gray("gray"),
|
||||
amber("amber");
|
||||
|
||||
final String value;
|
||||
const AvatarColor(this.value);
|
||||
|
||||
Color toColor({bool isDarkTheme = false}) => switch (this) {
|
||||
AvatarColor.primary => isDarkTheme ? const Color(0xFFABCBFA) : const Color(0xFF4250AF),
|
||||
AvatarColor.pink => const Color.fromARGB(255, 244, 114, 182),
|
||||
AvatarColor.red => const Color.fromARGB(255, 239, 68, 68),
|
||||
AvatarColor.yellow => const Color.fromARGB(255, 234, 179, 8),
|
||||
AvatarColor.blue => const Color.fromARGB(255, 59, 130, 246),
|
||||
AvatarColor.green => const Color.fromARGB(255, 22, 163, 74),
|
||||
AvatarColor.purple => const Color.fromARGB(255, 147, 51, 234),
|
||||
AvatarColor.orange => const Color.fromARGB(255, 234, 88, 12),
|
||||
AvatarColor.gray => const Color.fromARGB(255, 75, 85, 99),
|
||||
AvatarColor.amber => const Color.fromARGB(255, 217, 119, 6),
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Rename to User once Isar is removed
|
||||
class UserDto {
|
||||
@@ -9,9 +38,8 @@ class UserDto {
|
||||
final String email;
|
||||
final String name;
|
||||
final bool isAdmin;
|
||||
final DateTime updatedAt;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
final String? profileImagePath;
|
||||
final AvatarColor avatarColor;
|
||||
|
||||
final bool memoryEnabled;
|
||||
@@ -25,18 +53,22 @@ class UserDto {
|
||||
|
||||
bool get hasQuota => quotaSizeInBytes > 0;
|
||||
|
||||
final bool hasProfileImage;
|
||||
final DateTime profileChangedAt;
|
||||
|
||||
const UserDto({
|
||||
required this.id,
|
||||
required this.email,
|
||||
required this.name,
|
||||
required this.isAdmin,
|
||||
required this.updatedAt,
|
||||
this.profileImagePath,
|
||||
this.isAdmin = false,
|
||||
this.updatedAt,
|
||||
required this.profileChangedAt,
|
||||
this.avatarColor = AvatarColor.primary,
|
||||
this.memoryEnabled = true,
|
||||
this.inTimeline = false,
|
||||
this.isPartnerSharedBy = false,
|
||||
this.isPartnerSharedWith = false,
|
||||
this.hasProfileImage = false,
|
||||
this.quotaUsageInBytes = 0,
|
||||
this.quotaSizeInBytes = 0,
|
||||
});
|
||||
@@ -49,14 +81,13 @@ email: $email,
|
||||
name: $name,
|
||||
isAdmin: $isAdmin,
|
||||
updatedAt: $updatedAt,
|
||||
profileImagePath: ${profileImagePath ?? '<NA>'},
|
||||
avatarColor: $avatarColor,
|
||||
memoryEnabled: $memoryEnabled,
|
||||
inTimeline: $inTimeline,
|
||||
isPartnerSharedBy: $isPartnerSharedBy,
|
||||
isPartnerSharedWith: $isPartnerSharedWith,
|
||||
quotaUsageInBytes: $quotaUsageInBytes,
|
||||
quotaSizeInBytes: $quotaSizeInBytes,
|
||||
hasProfileImage: $hasProfileImage
|
||||
profileChangedAt: $profileChangedAt
|
||||
}''';
|
||||
}
|
||||
|
||||
@@ -66,48 +97,51 @@ quotaSizeInBytes: $quotaSizeInBytes,
|
||||
String? name,
|
||||
bool? isAdmin,
|
||||
DateTime? updatedAt,
|
||||
String? profileImagePath,
|
||||
AvatarColor? avatarColor,
|
||||
bool? memoryEnabled,
|
||||
bool? inTimeline,
|
||||
bool? isPartnerSharedBy,
|
||||
bool? isPartnerSharedWith,
|
||||
int? quotaUsageInBytes,
|
||||
bool? hasProfileImage,
|
||||
DateTime? profileChangedAt,
|
||||
int? quotaSizeInBytes,
|
||||
}) =>
|
||||
UserDto(
|
||||
id: id ?? this.id,
|
||||
email: email ?? this.email,
|
||||
name: name ?? this.name,
|
||||
isAdmin: isAdmin ?? this.isAdmin,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
profileImagePath: profileImagePath ?? this.profileImagePath,
|
||||
avatarColor: avatarColor ?? this.avatarColor,
|
||||
memoryEnabled: memoryEnabled ?? this.memoryEnabled,
|
||||
inTimeline: inTimeline ?? this.inTimeline,
|
||||
isPartnerSharedBy: isPartnerSharedBy ?? this.isPartnerSharedBy,
|
||||
isPartnerSharedWith: isPartnerSharedWith ?? this.isPartnerSharedWith,
|
||||
quotaUsageInBytes: quotaUsageInBytes ?? this.quotaUsageInBytes,
|
||||
quotaSizeInBytes: quotaSizeInBytes ?? this.quotaSizeInBytes,
|
||||
);
|
||||
int? quotaUsageInBytes,
|
||||
}) => UserDto(
|
||||
id: id ?? this.id,
|
||||
email: email ?? this.email,
|
||||
name: name ?? this.name,
|
||||
isAdmin: isAdmin ?? this.isAdmin,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
avatarColor: avatarColor ?? this.avatarColor,
|
||||
memoryEnabled: memoryEnabled ?? this.memoryEnabled,
|
||||
inTimeline: inTimeline ?? this.inTimeline,
|
||||
isPartnerSharedBy: isPartnerSharedBy ?? this.isPartnerSharedBy,
|
||||
isPartnerSharedWith: isPartnerSharedWith ?? this.isPartnerSharedWith,
|
||||
hasProfileImage: hasProfileImage ?? this.hasProfileImage,
|
||||
profileChangedAt: profileChangedAt ?? this.profileChangedAt,
|
||||
quotaSizeInBytes: quotaSizeInBytes ?? this.quotaSizeInBytes,
|
||||
quotaUsageInBytes: quotaUsageInBytes ?? this.quotaUsageInBytes,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(covariant UserDto other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.id == id &&
|
||||
other.updatedAt.isAtSameMomentAs(updatedAt) &&
|
||||
((updatedAt == null && other.updatedAt == null) ||
|
||||
(updatedAt != null && other.updatedAt != null && other.updatedAt!.isAtSameMomentAs(updatedAt!))) &&
|
||||
other.avatarColor == avatarColor &&
|
||||
other.email == email &&
|
||||
other.name == name &&
|
||||
other.isPartnerSharedBy == isPartnerSharedBy &&
|
||||
other.isPartnerSharedWith == isPartnerSharedWith &&
|
||||
other.profileImagePath == profileImagePath &&
|
||||
other.isAdmin == isAdmin &&
|
||||
other.memoryEnabled == memoryEnabled &&
|
||||
other.inTimeline == inTimeline &&
|
||||
other.quotaUsageInBytes == quotaUsageInBytes &&
|
||||
other.quotaSizeInBytes == quotaSizeInBytes;
|
||||
other.hasProfileImage == hasProfileImage &&
|
||||
other.profileChangedAt.isAtSameMomentAs(profileChangedAt) &&
|
||||
other.quotaSizeInBytes == quotaSizeInBytes &&
|
||||
other.quotaUsageInBytes == quotaUsageInBytes;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -117,14 +151,15 @@ quotaSizeInBytes: $quotaSizeInBytes,
|
||||
email.hashCode ^
|
||||
updatedAt.hashCode ^
|
||||
isAdmin.hashCode ^
|
||||
profileImagePath.hashCode ^
|
||||
avatarColor.hashCode ^
|
||||
memoryEnabled.hashCode ^
|
||||
inTimeline.hashCode ^
|
||||
isPartnerSharedBy.hashCode ^
|
||||
isPartnerSharedWith.hashCode ^
|
||||
quotaUsageInBytes.hashCode ^
|
||||
quotaSizeInBytes.hashCode;
|
||||
hasProfileImage.hashCode ^
|
||||
profileChangedAt.hashCode ^
|
||||
quotaSizeInBytes.hashCode ^
|
||||
quotaUsageInBytes.hashCode;
|
||||
}
|
||||
|
||||
class PartnerUserDto {
|
||||
@@ -143,13 +178,7 @@ class PartnerUserDto {
|
||||
this.profileImagePath,
|
||||
});
|
||||
|
||||
PartnerUserDto copyWith({
|
||||
String? id,
|
||||
String? email,
|
||||
String? name,
|
||||
bool? inTimeline,
|
||||
String? profileImagePath,
|
||||
}) {
|
||||
PartnerUserDto copyWith({String? id, String? email, String? name, bool? inTimeline, String? profileImagePath}) {
|
||||
return PartnerUserDto(
|
||||
id: id ?? this.id,
|
||||
email: email ?? this.email,
|
||||
@@ -175,16 +204,13 @@ class PartnerUserDto {
|
||||
email: map['email'] as String,
|
||||
name: map['name'] as String,
|
||||
inTimeline: map['inTimeline'] as bool,
|
||||
profileImagePath: map['profileImagePath'] != null
|
||||
? map['profileImagePath'] as String
|
||||
: null,
|
||||
profileImagePath: map['profileImagePath'] != null ? map['profileImagePath'] as String : null,
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory PartnerUserDto.fromJson(String source) =>
|
||||
PartnerUserDto.fromMap(json.decode(source) as Map<String, dynamic>);
|
||||
factory PartnerUserDto.fromJson(String source) => PartnerUserDto.fromMap(json.decode(source) as Map<String, dynamic>);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
@@ -204,10 +230,6 @@ class PartnerUserDto {
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return id.hashCode ^
|
||||
email.hashCode ^
|
||||
name.hashCode ^
|
||||
inTimeline.hashCode ^
|
||||
profileImagePath.hashCode;
|
||||
return id.hashCode ^ email.hashCode ^ name.hashCode ^ inTimeline.hashCode ^ profileImagePath.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'dart:ui';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
|
||||
enum UserMetadataKey {
|
||||
// do not change this order!
|
||||
@@ -7,37 +7,6 @@ enum UserMetadataKey {
|
||||
license,
|
||||
}
|
||||
|
||||
enum AvatarColor {
|
||||
// do not change this order or reuse indices for other purposes, adding is OK
|
||||
primary("primary"),
|
||||
pink("pink"),
|
||||
red("red"),
|
||||
yellow("yellow"),
|
||||
blue("blue"),
|
||||
green("green"),
|
||||
purple("purple"),
|
||||
orange("orange"),
|
||||
gray("gray"),
|
||||
amber("amber");
|
||||
|
||||
final String value;
|
||||
const AvatarColor(this.value);
|
||||
|
||||
Color toColor({bool isDarkTheme = false}) => switch (this) {
|
||||
AvatarColor.primary =>
|
||||
isDarkTheme ? const Color(0xFFABCBFA) : const Color(0xFF4250AF),
|
||||
AvatarColor.pink => const Color.fromARGB(255, 244, 114, 182),
|
||||
AvatarColor.red => const Color.fromARGB(255, 239, 68, 68),
|
||||
AvatarColor.yellow => const Color.fromARGB(255, 234, 179, 8),
|
||||
AvatarColor.blue => const Color.fromARGB(255, 59, 130, 246),
|
||||
AvatarColor.green => const Color.fromARGB(255, 22, 163, 74),
|
||||
AvatarColor.purple => const Color.fromARGB(255, 147, 51, 234),
|
||||
AvatarColor.orange => const Color.fromARGB(255, 234, 88, 12),
|
||||
AvatarColor.gray => const Color.fromARGB(255, 75, 85, 99),
|
||||
AvatarColor.amber => const Color.fromARGB(255, 217, 119, 6),
|
||||
};
|
||||
}
|
||||
|
||||
class Onboarding {
|
||||
final bool isOnboarded;
|
||||
|
||||
@@ -75,7 +44,6 @@ isOnboarded: $isOnboarded,
|
||||
int get hashCode => isOnboarded.hashCode;
|
||||
}
|
||||
|
||||
// TODO: wait to be overwritten
|
||||
class Preferences {
|
||||
final bool foldersEnabled;
|
||||
final bool memoriesEnabled;
|
||||
@@ -134,17 +102,17 @@ class Preferences {
|
||||
|
||||
factory Preferences.fromMap(Map<String, Object?> map) {
|
||||
return Preferences(
|
||||
foldersEnabled: map["folders-Enabled"] as bool? ?? false,
|
||||
memoriesEnabled: map["memories-Enabled"] as bool? ?? true,
|
||||
peopleEnabled: map["people-Enabled"] as bool? ?? true,
|
||||
ratingsEnabled: map["ratings-Enabled"] as bool? ?? false,
|
||||
sharedLinksEnabled: map["sharedLinks-Enabled"] as bool? ?? true,
|
||||
tagsEnabled: map["tags-Enabled"] as bool? ?? false,
|
||||
foldersEnabled: (map["folders"] as Map<String, Object?>?)?["enabled"] as bool? ?? false,
|
||||
memoriesEnabled: (map["memories"] as Map<String, Object?>?)?["enabled"] as bool? ?? true,
|
||||
peopleEnabled: (map["people"] as Map<String, Object?>?)?["enabled"] as bool? ?? true,
|
||||
ratingsEnabled: (map["ratings"] as Map<String, Object?>?)?["enabled"] as bool? ?? false,
|
||||
sharedLinksEnabled: (map["sharedLinks"] as Map<String, Object?>?)?["enabled"] as bool? ?? true,
|
||||
tagsEnabled: (map["tags"] as Map<String, Object?>?)?["enabled"] as bool? ?? false,
|
||||
userAvatarColor: AvatarColor.values.firstWhere(
|
||||
(e) => e.value == map["avatar-Color"] as String?,
|
||||
(e) => e.value == (map["avatar"] as Map<String, Object?>?)?["color"] as String?,
|
||||
orElse: () => AvatarColor.primary,
|
||||
),
|
||||
showSupportBadge: map["purchase-ShowSupportBadge"] as bool? ?? true,
|
||||
showSupportBadge: (map["purchase"] as Map<String, Object?>?)?["showSupportBadge"] as bool? ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -194,17 +162,9 @@ class License {
|
||||
final String activationKey;
|
||||
final String licenseKey;
|
||||
|
||||
const License({
|
||||
required this.activatedAt,
|
||||
required this.activationKey,
|
||||
required this.licenseKey,
|
||||
});
|
||||
const License({required this.activatedAt, required this.activationKey, required this.licenseKey});
|
||||
|
||||
License copyWith({
|
||||
DateTime? activatedAt,
|
||||
String? activationKey,
|
||||
String? licenseKey,
|
||||
}) {
|
||||
License copyWith({DateTime? activatedAt, String? activationKey, String? licenseKey}) {
|
||||
return License(
|
||||
activatedAt: activatedAt ?? this.activatedAt,
|
||||
activationKey: activationKey ?? this.activationKey,
|
||||
@@ -222,7 +182,7 @@ class License {
|
||||
|
||||
factory License.fromMap(Map<String, Object?> map) {
|
||||
return License(
|
||||
activatedAt: map["activatedAt"] as DateTime,
|
||||
activatedAt: DateTime.parse(map["activatedAt"] as String),
|
||||
activationKey: map["activationKey"] as String,
|
||||
licenseKey: map["licenseKey"] as String,
|
||||
);
|
||||
@@ -241,14 +201,11 @@ licenseKey: $licenseKey,
|
||||
bool operator ==(covariant License other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return activatedAt == other.activatedAt &&
|
||||
activationKey == other.activationKey &&
|
||||
licenseKey == other.licenseKey;
|
||||
return activatedAt == other.activatedAt && activationKey == other.activationKey && licenseKey == other.licenseKey;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
activatedAt.hashCode ^ activationKey.hashCode ^ licenseKey.hashCode;
|
||||
int get hashCode => activatedAt.hashCode ^ activationKey.hashCode ^ licenseKey.hashCode;
|
||||
}
|
||||
|
||||
// Model for a user metadata stored in the server
|
||||
@@ -259,16 +216,11 @@ class UserMetadata {
|
||||
final Preferences? preferences;
|
||||
final License? license;
|
||||
|
||||
const UserMetadata({
|
||||
required this.userId,
|
||||
required this.key,
|
||||
this.onboarding,
|
||||
this.preferences,
|
||||
this.license,
|
||||
}) : assert(
|
||||
onboarding != null || preferences != null || license != null,
|
||||
'One of onboarding, preferences and license must be provided',
|
||||
);
|
||||
const UserMetadata({required this.userId, required this.key, this.onboarding, this.preferences, this.license})
|
||||
: assert(
|
||||
onboarding != null || preferences != null || license != null,
|
||||
'One of onboarding, preferences and license must be provided',
|
||||
);
|
||||
|
||||
UserMetadata copyWith({
|
||||
String? userId,
|
||||
@@ -310,10 +262,6 @@ license: ${license ?? "<NA>"},
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return userId.hashCode ^
|
||||
key.hashCode ^
|
||||
onboarding.hashCode ^
|
||||
preferences.hashCode ^
|
||||
license.hashCode;
|
||||
return userId.hashCode ^ key.hashCode ^ onboarding.hashCode ^ preferences.hashCode ^ license.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,51 @@
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
|
||||
import 'package:platform/platform.dart';
|
||||
|
||||
class AssetService {
|
||||
final RemoteAssetRepository _remoteAssetRepository;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final Platform _platform;
|
||||
|
||||
const AssetService({
|
||||
required RemoteAssetRepository remoteAssetRepository,
|
||||
required DriftLocalAssetRepository localAssetRepository,
|
||||
}) : _remoteAssetRepository = remoteAssetRepository,
|
||||
_localAssetRepository = localAssetRepository,
|
||||
_platform = const LocalPlatform();
|
||||
}) : _remoteAssetRepository = remoteAssetRepository,
|
||||
_localAssetRepository = localAssetRepository;
|
||||
|
||||
Future<BaseAsset?> getAsset(BaseAsset asset) {
|
||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
|
||||
return asset is LocalAsset ? _localAssetRepository.get(id) : _remoteAssetRepository.get(id);
|
||||
}
|
||||
|
||||
Stream<BaseAsset?> watchAsset(BaseAsset asset) {
|
||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
|
||||
return asset is LocalAsset
|
||||
? _localAssetRepository.watchAsset(id)
|
||||
: _remoteAssetRepository.watchAsset(id);
|
||||
return asset is LocalAsset ? _localAssetRepository.watch(id) : _remoteAssetRepository.watch(id);
|
||||
}
|
||||
|
||||
Future<List<LocalAsset?>> getLocalAssetsByChecksum(String checksum) {
|
||||
return _localAssetRepository.getByChecksum(checksum);
|
||||
}
|
||||
|
||||
Future<RemoteAsset?> getRemoteAssetByChecksum(String checksum) {
|
||||
return _remoteAssetRepository.getByChecksum(checksum);
|
||||
}
|
||||
|
||||
Future<RemoteAsset?> getRemoteAsset(String id) {
|
||||
return _remoteAssetRepository.get(id);
|
||||
}
|
||||
|
||||
Future<List<RemoteAsset>> getStack(RemoteAsset asset) async {
|
||||
if (asset.stackId == null) {
|
||||
return [];
|
||||
return const [];
|
||||
}
|
||||
|
||||
return _remoteAssetRepository.getStackChildren(asset).then((assets) {
|
||||
// Include the primary asset in the stack as the first item
|
||||
return [asset, ...assets];
|
||||
});
|
||||
final stack = await _remoteAssetRepository.getStackChildren(asset);
|
||||
// Include the primary asset in the stack as the first item
|
||||
return [asset, ...stack];
|
||||
}
|
||||
|
||||
Future<ExifInfo?> getExif(BaseAsset asset) async {
|
||||
@@ -40,8 +53,7 @@ class AssetService {
|
||||
return null;
|
||||
}
|
||||
|
||||
final id =
|
||||
asset is LocalAsset ? asset.remoteId! : (asset as RemoteAsset).id;
|
||||
final id = asset is LocalAsset ? asset.remoteId! : (asset as RemoteAsset).id;
|
||||
return _remoteAssetRepository.getExif(id);
|
||||
}
|
||||
|
||||
@@ -56,8 +68,7 @@ class AssetService {
|
||||
width = exif?.width ?? asset.width?.toDouble();
|
||||
height = exif?.height ?? asset.height?.toDouble();
|
||||
} else if (asset is LocalAsset) {
|
||||
isFlipped = _platform.isAndroid &&
|
||||
(asset.orientation == 90 || asset.orientation == 270);
|
||||
isFlipped = CurrentPlatform.isAndroid && (asset.orientation == 90 || asset.orientation == 270);
|
||||
width = asset.width?.toDouble();
|
||||
height = asset.height?.toDouble();
|
||||
} else {
|
||||
@@ -73,7 +84,19 @@ class AssetService {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
Future<List<(String, String)>> getPlaces() {
|
||||
return _remoteAssetRepository.getPlaces();
|
||||
Future<List<(String, String)>> getPlaces(String userId) {
|
||||
return _remoteAssetRepository.getPlaces(userId);
|
||||
}
|
||||
|
||||
Future<(int local, int remote)> getAssetCounts() async {
|
||||
return (await _localAssetRepository.getCount(), await _remoteAssetRepository.getCount());
|
||||
}
|
||||
|
||||
Future<int> getLocalHashedCount() {
|
||||
return _localAssetRepository.getHashedCount();
|
||||
}
|
||||
|
||||
Future<List<LocalAlbum>> getSourceAlbums(String localAssetId, {BackupSelection? backupSelection}) {
|
||||
return _localAssetRepository.getSourceAlbums(localAssetId, backupSelection: backupSelection);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_api.g.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
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/infrastructure/platform.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';
|
||||
import 'package:immich_mobile/services/upload.service.dart';
|
||||
import 'package:immich_mobile/utils/bootstrap.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:immich_mobile/utils/http_ssl_options.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:worker_manager/worker_manager.dart';
|
||||
|
||||
class BackgroundWorkerFgService {
|
||||
final BackgroundWorkerFgHostApi _foregroundHostApi;
|
||||
|
||||
const BackgroundWorkerFgService(this._foregroundHostApi);
|
||||
|
||||
// TODO: Move this call to native side once old timeline is removed
|
||||
Future<void> enable() => _foregroundHostApi.enable();
|
||||
|
||||
Future<void> saveNotificationMessage(String title, String body) =>
|
||||
_foregroundHostApi.saveNotificationMessage(title, body);
|
||||
|
||||
Future<void> configure({int? minimumDelaySeconds, bool? requireCharging}) => _foregroundHostApi.configure(
|
||||
BackgroundWorkerSettings(
|
||||
minimumDelaySeconds:
|
||||
minimumDelaySeconds ??
|
||||
Store.get(AppSettingsEnum.backupTriggerDelay.storeKey, AppSettingsEnum.backupTriggerDelay.defaultValue),
|
||||
requiresCharging:
|
||||
requireCharging ??
|
||||
Store.get(AppSettingsEnum.backupRequireCharging.storeKey, AppSettingsEnum.backupRequireCharging.defaultValue),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> disable() => _foregroundHostApi.disable();
|
||||
}
|
||||
|
||||
class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
ProviderContainer? _ref;
|
||||
final Isar _isar;
|
||||
final Drift _drift;
|
||||
final DriftLogger _driftLogger;
|
||||
final BackgroundWorkerBgHostApi _backgroundHostApi;
|
||||
final CancellationToken _cancellationToken = CancellationToken();
|
||||
final Logger _logger = Logger('BackgroundWorkerBgService');
|
||||
|
||||
bool _isCleanedUp = false;
|
||||
|
||||
BackgroundWorkerBgService({required Isar isar, required Drift drift, required DriftLogger driftLogger})
|
||||
: _isar = isar,
|
||||
_drift = drift,
|
||||
_driftLogger = driftLogger,
|
||||
_backgroundHostApi = BackgroundWorkerBgHostApi() {
|
||||
_ref = ProviderContainer(
|
||||
overrides: [
|
||||
dbProvider.overrideWithValue(isar),
|
||||
isarProvider.overrideWithValue(isar),
|
||||
driftProvider.overrideWith(driftOverride(drift)),
|
||||
],
|
||||
);
|
||||
BackgroundWorkerFlutterApi.setUp(this);
|
||||
}
|
||||
|
||||
bool get _isBackupEnabled => _ref?.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup) ?? false;
|
||||
|
||||
Future<void> init() async {
|
||||
try {
|
||||
HttpSSLOptions.apply(applyNative: false);
|
||||
|
||||
await Future.wait(
|
||||
[
|
||||
loadTranslations(),
|
||||
workerManager.init(dynamicSpawning: true),
|
||||
_ref?.read(authServiceProvider).setOpenApiServiceEndpoint(),
|
||||
// Initialize the file downloader
|
||||
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),
|
||||
],
|
||||
),
|
||||
FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false),
|
||||
FileDownloader().trackTasks(),
|
||||
_ref?.read(fileMediaRepositoryProvider).enableBackgroundAccess(),
|
||||
].nonNulls,
|
||||
);
|
||||
|
||||
configureFileDownloaderNotifications();
|
||||
|
||||
// Notify the host that the background worker service has been initialized and is ready to use
|
||||
_backgroundHostApi.onInitialized();
|
||||
} catch (error, stack) {
|
||||
_logger.severe("Failed to initialize background worker", error, stack);
|
||||
_backgroundHostApi.close();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAndroidUpload() async {
|
||||
_logger.info('Android background processing started');
|
||||
final sw = Stopwatch()..start();
|
||||
try {
|
||||
if (!await _syncAssets(hashTimeout: Duration(minutes: _isBackupEnabled ? 3 : 6))) {
|
||||
_logger.warning("Remote sync did not complete successfully, skipping backup");
|
||||
return;
|
||||
}
|
||||
await _handleBackup();
|
||||
} catch (error, stack) {
|
||||
_logger.severe("Failed to complete Android background processing", error, stack);
|
||||
} finally {
|
||||
sw.stop();
|
||||
_logger.info("Android background processing completed in ${sw.elapsed.inSeconds}s");
|
||||
await _cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onIosUpload(bool isRefresh, int? maxSeconds) async {
|
||||
_logger.info('iOS background upload started with maxSeconds: ${maxSeconds}s');
|
||||
final sw = Stopwatch()..start();
|
||||
try {
|
||||
final timeout = isRefresh ? const Duration(seconds: 5) : Duration(minutes: _isBackupEnabled ? 3 : 6);
|
||||
if (!await _syncAssets(hashTimeout: timeout)) {
|
||||
_logger.warning("Remote sync did not complete successfully, skipping backup");
|
||||
return;
|
||||
}
|
||||
|
||||
final backupFuture = _handleBackup();
|
||||
if (maxSeconds != null) {
|
||||
await backupFuture.timeout(Duration(seconds: maxSeconds - 1), onTimeout: () {});
|
||||
} else {
|
||||
await backupFuture;
|
||||
}
|
||||
} catch (error, stack) {
|
||||
_logger.severe("Failed to complete iOS background upload", error, stack);
|
||||
} finally {
|
||||
sw.stop();
|
||||
_logger.info("iOS background upload completed in ${sw.elapsed.inSeconds}s");
|
||||
await _cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cancel() async {
|
||||
_logger.warning("Background worker cancelled");
|
||||
try {
|
||||
await _cleanup();
|
||||
} catch (error, stack) {
|
||||
dPrint(() => 'Failed to cleanup background worker: $error with stack: $stack');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cleanup() async {
|
||||
// If ref is null, it means the service was never initialized properly
|
||||
if (_isCleanedUp || _ref == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
_isCleanedUp = true;
|
||||
final backgroundSyncManager = _ref?.read(backgroundSyncProvider);
|
||||
final nativeSyncApi = _ref?.read(nativeSyncApiProvider);
|
||||
_ref?.dispose();
|
||||
_ref = null;
|
||||
|
||||
_cancellationToken.cancel();
|
||||
_logger.info("Cleaning up background worker");
|
||||
final cleanupFutures = [
|
||||
workerManager.dispose().catchError((_) async {
|
||||
// Discard any errors on the dispose call
|
||||
return;
|
||||
}),
|
||||
LogService.I.dispose(),
|
||||
Store.dispose(),
|
||||
_drift.close(),
|
||||
_driftLogger.close(),
|
||||
backgroundSyncManager?.cancel(),
|
||||
nativeSyncApi?.cancelHashing(),
|
||||
];
|
||||
|
||||
if (_isar.isOpen) {
|
||||
cleanupFutures.add(_isar.close());
|
||||
}
|
||||
await Future.wait(cleanupFutures.nonNulls);
|
||||
_logger.info("Background worker resources cleaned up");
|
||||
} catch (error, stack) {
|
||||
dPrint(() => 'Failed to cleanup background worker: $error with stack: $stack');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleBackup() async {
|
||||
await runZonedGuarded(
|
||||
() async {
|
||||
if (_isCleanedUp) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_isBackupEnabled) {
|
||||
_logger.info("Backup is disabled. Skipping backup routine");
|
||||
return;
|
||||
}
|
||||
|
||||
final currentUser = _ref?.read(currentUserProvider);
|
||||
if (currentUser == null) {
|
||||
_logger.warning("No current user found. Skipping backup from background");
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.isIOS) {
|
||||
return _ref?.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id);
|
||||
}
|
||||
|
||||
final networkCapabilities = await _ref?.read(connectivityApiProvider).getCapabilities() ?? [];
|
||||
return _ref
|
||||
?.read(uploadServiceProvider)
|
||||
.startBackupWithHttpClient(currentUser.id, networkCapabilities.hasWifi, _cancellationToken);
|
||||
},
|
||||
(error, stack) {
|
||||
dPrint(() => "Error in backup zone $error, $stack");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _syncAssets({Duration? hashTimeout}) async {
|
||||
await _ref?.read(backgroundSyncProvider).syncLocal();
|
||||
if (_isCleanedUp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final isSuccess = await _ref?.read(backgroundSyncProvider).syncRemote() ?? false;
|
||||
if (_isCleanedUp) {
|
||||
return isSuccess;
|
||||
}
|
||||
|
||||
var hashFuture = _ref?.read(backgroundSyncProvider).hashAssets();
|
||||
if (hashTimeout != null && hashFuture != null) {
|
||||
hashFuture = hashFuture.timeout(
|
||||
hashTimeout,
|
||||
onTimeout: () {
|
||||
// Consume cancellation errors as we want to continue processing
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await hashFuture;
|
||||
return isSuccess;
|
||||
}
|
||||
}
|
||||
|
||||
class BackgroundWorkerLockService {
|
||||
final BackgroundWorkerLockApi _hostApi;
|
||||
const BackgroundWorkerLockService(this._hostApi);
|
||||
|
||||
Future<void> lock() async {
|
||||
if (CurrentPlatform.isAndroid) {
|
||||
return _hostApi.lock();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> unlock() async {
|
||||
if (CurrentPlatform.isAndroid) {
|
||||
return _hostApi.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
DartPluginRegistrant.ensureInitialized();
|
||||
|
||||
final (isar, drift, logDB) = await Bootstrap.initDB();
|
||||
await Bootstrap.initDomain(isar, drift, logDB, shouldBufferLogs: false, listenStoreUpdates: false);
|
||||
await BackgroundWorkerBgService(isar: isar, drift: drift, driftLogger: logDB).init();
|
||||
}
|
||||
@@ -1,120 +1,126 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/presentation/pages/dev/dev_logger.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
const String _kHashCancelledCode = "HASH_CANCELLED";
|
||||
|
||||
class HashService {
|
||||
final int batchSizeLimit;
|
||||
final int batchFileLimit;
|
||||
final int _batchSize;
|
||||
final DriftLocalAlbumRepository _localAlbumRepository;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final StorageRepository _storageRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final bool Function()? _cancelChecker;
|
||||
final _log = Logger('HashService');
|
||||
|
||||
HashService({
|
||||
required DriftLocalAlbumRepository localAlbumRepository,
|
||||
required DriftLocalAssetRepository localAssetRepository,
|
||||
required StorageRepository storageRepository,
|
||||
required NativeSyncApi nativeSyncApi,
|
||||
this.batchSizeLimit = kBatchHashSizeLimit,
|
||||
this.batchFileLimit = kBatchHashFileLimit,
|
||||
}) : _localAlbumRepository = localAlbumRepository,
|
||||
_localAssetRepository = localAssetRepository,
|
||||
_storageRepository = storageRepository,
|
||||
_nativeSyncApi = nativeSyncApi;
|
||||
bool Function()? cancelChecker,
|
||||
int? batchSize,
|
||||
}) : _localAlbumRepository = localAlbumRepository,
|
||||
_localAssetRepository = localAssetRepository,
|
||||
_cancelChecker = cancelChecker,
|
||||
_nativeSyncApi = nativeSyncApi,
|
||||
_batchSize = batchSize ?? kBatchHashFileLimit;
|
||||
|
||||
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(
|
||||
sortBy: {
|
||||
SortLocalAlbumsBy.backupSelection,
|
||||
SortLocalAlbumsBy.isIosSharedAlbum,
|
||||
},
|
||||
);
|
||||
try {
|
||||
// Sorted by backupSelection followed by isCloud
|
||||
final localAlbums = await _localAlbumRepository.getBackupAlbums();
|
||||
|
||||
for (final album in localAlbums) {
|
||||
final assetsToHash =
|
||||
await _localAlbumRepository.getAssetsToHash(album.id);
|
||||
if (assetsToHash.isNotEmpty) {
|
||||
await _hashAssets(assetsToHash);
|
||||
for (final album in localAlbums) {
|
||||
if (isCancelled) {
|
||||
_log.warning("Hashing cancelled. Stopped processing albums.");
|
||||
break;
|
||||
}
|
||||
|
||||
final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
|
||||
if (assetsToHash.isNotEmpty) {
|
||||
await _hashAssets(album, assetsToHash);
|
||||
}
|
||||
}
|
||||
} on PlatformException catch (e) {
|
||||
if (e.code == _kHashCancelledCode) {
|
||||
_log.warning("Hashing cancelled by platform");
|
||||
return;
|
||||
}
|
||||
} catch (e, s) {
|
||||
_log.severe("Error during hashing", e, s);
|
||||
}
|
||||
|
||||
stopwatch.stop();
|
||||
_log.info("Hashing took - ${stopwatch.elapsedMilliseconds}ms");
|
||||
DLog.log("Hashing took - ${stopwatch.elapsedMilliseconds}ms");
|
||||
}
|
||||
|
||||
/// 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(List<LocalAsset> assetsToHash) async {
|
||||
int bytesProcessed = 0;
|
||||
final toHash = <_AssetToPath>[];
|
||||
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash) async {
|
||||
final toHash = <String, LocalAsset>{};
|
||||
|
||||
for (final asset in assetsToHash) {
|
||||
final file = await _storageRepository.getFileForAsset(asset.id);
|
||||
if (file == null) {
|
||||
continue;
|
||||
if (isCancelled) {
|
||||
_log.warning("Hashing cancelled. Stopped processing assets.");
|
||||
return;
|
||||
}
|
||||
|
||||
bytesProcessed += await file.length();
|
||||
toHash.add(_AssetToPath(asset: asset, path: file.path));
|
||||
|
||||
if (toHash.length >= batchFileLimit || bytesProcessed >= batchSizeLimit) {
|
||||
await _processBatch(toHash);
|
||||
toHash[asset.id] = asset;
|
||||
if (toHash.length == _batchSize) {
|
||||
await _processBatch(album, toHash);
|
||||
toHash.clear();
|
||||
bytesProcessed = 0;
|
||||
}
|
||||
}
|
||||
|
||||
await _processBatch(toHash);
|
||||
await _processBatch(album, toHash);
|
||||
}
|
||||
|
||||
/// Processes a batch of assets.
|
||||
Future<void> _processBatch(List<_AssetToPath> toHash) async {
|
||||
Future<void> _processBatch(LocalAlbum album, Map<String, LocalAsset> toHash) async {
|
||||
if (toHash.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
_log.fine("Hashing ${toHash.length} files");
|
||||
|
||||
final hashed = <LocalAsset>[];
|
||||
final hashes =
|
||||
await _nativeSyncApi.hashPaths(toHash.map((e) => e.path).toList());
|
||||
final hashed = <String, String>{};
|
||||
final hashResults = await _nativeSyncApi.hashAssets(
|
||||
toHash.keys.toList(),
|
||||
allowNetworkAccess: album.backupSelection == BackupSelection.selected,
|
||||
);
|
||||
assert(
|
||||
hashes.length == toHash.length,
|
||||
"Hashes length does not match toHash length: ${hashes.length} != ${toHash.length}",
|
||||
hashResults.length == toHash.length,
|
||||
"Hashes length does not match toHash length: ${hashResults.length} != ${toHash.length}",
|
||||
);
|
||||
|
||||
for (int i = 0; i < hashes.length; i++) {
|
||||
final hash = hashes[i];
|
||||
final asset = toHash[i].asset;
|
||||
if (hash?.length == 20) {
|
||||
hashed.add(asset.copyWith(checksum: base64.encode(hash!)));
|
||||
for (int i = 0; i < hashResults.length; i++) {
|
||||
if (isCancelled) {
|
||||
_log.warning("Hashing cancelled. Stopped processing batch.");
|
||||
return;
|
||||
}
|
||||
|
||||
final hashResult = hashResults[i];
|
||||
if (hashResult.hash != null) {
|
||||
hashed[hashResult.assetId] = hashResult.hash!;
|
||||
} else {
|
||||
_log.warning("Failed to hash file for ${asset.id}");
|
||||
final asset = toHash[hashResult.assetId];
|
||||
_log.warning(
|
||||
"Failed to hash asset with id: ${hashResult.assetId}, name: ${asset?.name}, createdAt: ${asset?.createdAt}, from album: ${album.name}. Error: ${hashResult.error ?? "unknown"}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_log.fine("Hashed ${hashed.length}/${toHash.length} assets");
|
||||
DLog.log("Hashed ${hashed.length}/${toHash.length} assets");
|
||||
|
||||
await _localAssetRepository.updateHashes(hashed);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssetToPath {
|
||||
final LocalAsset asset;
|
||||
final String path;
|
||||
|
||||
const _AssetToPath({required this.asset, required this.path});
|
||||
}
|
||||
|
||||
@@ -18,4 +18,20 @@ class LocalAlbumService {
|
||||
Future<void> update(LocalAlbum album) {
|
||||
return _repository.upsert(album);
|
||||
}
|
||||
|
||||
Future<int> getCount() {
|
||||
return _repository.getCount();
|
||||
}
|
||||
|
||||
Future<void> unlinkRemoteAlbum(String id) async {
|
||||
return _repository.unlinkRemoteAlbum(id);
|
||||
}
|
||||
|
||||
Future<void> linkRemoteAlbum(String localAlbumId, String remoteAlbumId) async {
|
||||
return _repository.linkRemoteAlbum(localAlbumId, remoteAlbumId);
|
||||
}
|
||||
|
||||
Future<List<LocalAlbum>> getBackupAlbums() {
|
||||
return _repository.getBackupAlbums();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +1,41 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/foundation.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/extensions/platform_extensions.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/presentation/pages/dev/dev_logger.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';
|
||||
|
||||
class LocalSyncService {
|
||||
final DriftLocalAlbumRepository _localAlbumRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final Platform _platform;
|
||||
final Logger _log = Logger("DeviceSyncService");
|
||||
|
||||
LocalSyncService({
|
||||
required DriftLocalAlbumRepository localAlbumRepository,
|
||||
required NativeSyncApi nativeSyncApi,
|
||||
Platform? platform,
|
||||
}) : _localAlbumRepository = localAlbumRepository,
|
||||
_nativeSyncApi = nativeSyncApi,
|
||||
_platform = platform ?? const LocalPlatform();
|
||||
LocalSyncService({required DriftLocalAlbumRepository localAlbumRepository, required NativeSyncApi nativeSyncApi})
|
||||
: _localAlbumRepository = localAlbumRepository,
|
||||
_nativeSyncApi = nativeSyncApi;
|
||||
|
||||
Future<void> sync({bool full = false}) async {
|
||||
final Stopwatch stopwatch = Stopwatch()..start();
|
||||
try {
|
||||
if (full || await _nativeSyncApi.shouldFullSync()) {
|
||||
_log.fine("Full sync request from ${full ? "user" : "native"}");
|
||||
DLog.log("Full sync request from ${full ? "user" : "native"}");
|
||||
return await fullSync();
|
||||
}
|
||||
|
||||
final delta = await _nativeSyncApi.getMediaChanges();
|
||||
if (!delta.hasChanges) {
|
||||
_log.fine("No media changes detected. Skipping sync");
|
||||
DLog.log("No media changes detected. Skipping sync");
|
||||
return;
|
||||
}
|
||||
|
||||
DLog.log("Delta updated: ${delta.updates.length}");
|
||||
DLog.log("Delta deleted: ${delta.deletes.length}");
|
||||
_log.fine("Delta updated: ${delta.updates.length}");
|
||||
_log.fine("Delta deleted: ${delta.deletes.length}");
|
||||
|
||||
final deviceAlbums = await _nativeSyncApi.getAlbums();
|
||||
await _localAlbumRepository.updateAll(deviceAlbums.toLocalAlbums());
|
||||
@@ -55,25 +48,22 @@ class LocalSyncService {
|
||||
final dbAlbums = await _localAlbumRepository.getAll();
|
||||
// On Android, we need to sync all albums since it is not possible to
|
||||
// detect album deletions from the native side
|
||||
if (_platform.isAndroid) {
|
||||
if (CurrentPlatform.isAndroid) {
|
||||
for (final album in dbAlbums) {
|
||||
final deviceIds = await _nativeSyncApi.getAssetIdsForAlbum(album.id);
|
||||
await _localAlbumRepository.syncDeletes(album.id, deviceIds);
|
||||
}
|
||||
}
|
||||
|
||||
if (_platform.isIOS) {
|
||||
if (CurrentPlatform.isIOS) {
|
||||
// On iOS, we need to full sync albums that are marked as cloud as the delta sync
|
||||
// does not include changes for cloud albums. If ignoreIcloudAssets is enabled,
|
||||
// remove the albums from the local database from the previous sync
|
||||
final cloudAlbums =
|
||||
deviceAlbums.where((a) => a.isCloud).toLocalAlbums();
|
||||
final cloudAlbums = deviceAlbums.where((a) => a.isCloud).toLocalAlbums();
|
||||
for (final album in cloudAlbums) {
|
||||
final dbAlbum = dbAlbums.firstWhereOrNull((a) => a.id == album.id);
|
||||
if (dbAlbum == null) {
|
||||
_log.warning(
|
||||
"Cloud album ${album.name} not found in local database. Skipping sync.",
|
||||
);
|
||||
_log.warning("Cloud album ${album.name} not found in local database. Skipping sync.");
|
||||
continue;
|
||||
}
|
||||
await updateAlbum(dbAlbum, album);
|
||||
@@ -86,7 +76,6 @@ class LocalSyncService {
|
||||
} finally {
|
||||
stopwatch.stop();
|
||||
_log.info("Device sync took - ${stopwatch.elapsedMilliseconds}ms");
|
||||
DLog.log("Device sync took - ${stopwatch.elapsedMilliseconds}ms");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,8 +84,7 @@ class LocalSyncService {
|
||||
final Stopwatch stopwatch = Stopwatch()..start();
|
||||
|
||||
final deviceAlbums = await _nativeSyncApi.getAlbums();
|
||||
final dbAlbums =
|
||||
await _localAlbumRepository.getAll(sortBy: {SortLocalAlbumsBy.id});
|
||||
final dbAlbums = await _localAlbumRepository.getAll(sortBy: {SortLocalAlbumsBy.id});
|
||||
|
||||
await diffSortedLists(
|
||||
dbAlbums,
|
||||
@@ -110,7 +98,6 @@ class LocalSyncService {
|
||||
await _nativeSyncApi.checkpointSync();
|
||||
stopwatch.stop();
|
||||
_log.info("Full device sync took - ${stopwatch.elapsedMilliseconds}ms");
|
||||
DLog.log("Full device sync took - ${stopwatch.elapsedMilliseconds}ms");
|
||||
} catch (e, s) {
|
||||
_log.severe("Error performing full device sync", e, s);
|
||||
}
|
||||
@@ -120,14 +107,9 @@ class LocalSyncService {
|
||||
try {
|
||||
_log.fine("Adding device album ${album.name}");
|
||||
|
||||
final assets = album.assetCount > 0
|
||||
? await _nativeSyncApi.getAssetsForAlbum(album.id)
|
||||
: <PlatformAsset>[];
|
||||
final assets = album.assetCount > 0 ? await _nativeSyncApi.getAssetsForAlbum(album.id) : <PlatformAsset>[];
|
||||
|
||||
await _localAlbumRepository.upsert(
|
||||
album,
|
||||
toUpsert: assets.toLocalAssets(),
|
||||
);
|
||||
await _localAlbumRepository.upsert(album, toUpsert: assets.toLocalAssets());
|
||||
_log.fine("Successfully added device album ${album.name}");
|
||||
} catch (e, s) {
|
||||
_log.warning("Error while adding device album", e, s);
|
||||
@@ -150,9 +132,7 @@ class LocalSyncService {
|
||||
_log.fine("Syncing device album ${dbAlbum.name}");
|
||||
|
||||
if (_albumsEqual(deviceAlbum, dbAlbum)) {
|
||||
_log.fine(
|
||||
"Device album ${dbAlbum.name} has not changed. Skipping sync.",
|
||||
);
|
||||
_log.fine("Device album ${dbAlbum.name} has not changed. Skipping sync.");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -161,7 +141,6 @@ class LocalSyncService {
|
||||
// Faster path - only new assets added
|
||||
if (await checkAddition(dbAlbum, deviceAlbum)) {
|
||||
_log.fine("Fast synced device album ${dbAlbum.name}");
|
||||
DLog.log("Fast synced device album ${dbAlbum.name}");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -176,10 +155,7 @@ class LocalSyncService {
|
||||
@visibleForTesting
|
||||
// The [deviceAlbum] is expected to be refreshed before calling this method
|
||||
// with modified time and asset count
|
||||
Future<bool> checkAddition(
|
||||
LocalAlbum dbAlbum,
|
||||
LocalAlbum deviceAlbum,
|
||||
) async {
|
||||
Future<bool> checkAddition(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async {
|
||||
try {
|
||||
_log.fine("Fast syncing device album ${dbAlbum.name}");
|
||||
// Assets has been modified
|
||||
@@ -188,16 +164,12 @@ class LocalSyncService {
|
||||
return false;
|
||||
}
|
||||
|
||||
final updatedTime =
|
||||
(dbAlbum.updatedAt.millisecondsSinceEpoch ~/ 1000) + 1;
|
||||
final newAssetsCount =
|
||||
await _nativeSyncApi.getAssetsCountSince(deviceAlbum.id, updatedTime);
|
||||
final updatedTime = (dbAlbum.updatedAt.millisecondsSinceEpoch ~/ 1000) + 1;
|
||||
final newAssetsCount = await _nativeSyncApi.getAssetsCountSince(deviceAlbum.id, updatedTime);
|
||||
|
||||
// Early return if no new assets were found
|
||||
if (newAssetsCount == 0) {
|
||||
_log.fine(
|
||||
"No new assets found despite album having changes. Proceeding to full sync for ${dbAlbum.name}",
|
||||
);
|
||||
_log.fine("No new assets found despite album having changes. Proceeding to full sync for ${dbAlbum.name}");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -207,10 +179,7 @@ class LocalSyncService {
|
||||
return false;
|
||||
}
|
||||
|
||||
final newAssets = await _nativeSyncApi.getAssetsForAlbum(
|
||||
deviceAlbum.id,
|
||||
updatedTimeCond: updatedTime,
|
||||
);
|
||||
final newAssets = await _nativeSyncApi.getAssetsForAlbum(deviceAlbum.id, updatedTimeCond: updatedTime);
|
||||
|
||||
await _localAlbumRepository.upsert(
|
||||
deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection),
|
||||
@@ -230,18 +199,12 @@ class LocalSyncService {
|
||||
Future<bool> fullDiff(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async {
|
||||
try {
|
||||
final assetsInDevice = deviceAlbum.assetCount > 0
|
||||
? await _nativeSyncApi
|
||||
.getAssetsForAlbum(deviceAlbum.id)
|
||||
.then((a) => a.toLocalAssets())
|
||||
: <LocalAsset>[];
|
||||
final assetsInDb = dbAlbum.assetCount > 0
|
||||
? await _localAlbumRepository.getAssets(dbAlbum.id)
|
||||
? await _nativeSyncApi.getAssetsForAlbum(deviceAlbum.id).then((a) => a.toLocalAssets())
|
||||
: <LocalAsset>[];
|
||||
final assetsInDb = dbAlbum.assetCount > 0 ? await _localAlbumRepository.getAssets(dbAlbum.id) : <LocalAsset>[];
|
||||
|
||||
if (deviceAlbum.assetCount == 0) {
|
||||
_log.fine(
|
||||
"Device album ${deviceAlbum.name} is empty. Removing assets from DB.",
|
||||
);
|
||||
_log.fine("Device album ${deviceAlbum.name} is empty. Removing assets from DB.");
|
||||
await _localAlbumRepository.upsert(
|
||||
deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection),
|
||||
toDelete: assetsInDb.map((a) => a.id),
|
||||
@@ -249,18 +212,11 @@ class LocalSyncService {
|
||||
return true;
|
||||
}
|
||||
|
||||
final updatedDeviceAlbum = deviceAlbum.copyWith(
|
||||
backupSelection: dbAlbum.backupSelection,
|
||||
);
|
||||
final updatedDeviceAlbum = deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection);
|
||||
|
||||
if (dbAlbum.assetCount == 0) {
|
||||
_log.fine(
|
||||
"Device album ${deviceAlbum.name} is empty. Adding assets to DB.",
|
||||
);
|
||||
await _localAlbumRepository.upsert(
|
||||
updatedDeviceAlbum,
|
||||
toUpsert: assetsInDevice,
|
||||
);
|
||||
_log.fine("Device album ${deviceAlbum.name} is empty. Adding assets to DB.");
|
||||
await _localAlbumRepository.upsert(updatedDeviceAlbum, toUpsert: assetsInDevice);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -292,18 +248,12 @@ class LocalSyncService {
|
||||
);
|
||||
|
||||
if (assetsToUpsert.isEmpty && assetsToDelete.isEmpty) {
|
||||
_log.fine(
|
||||
"No asset changes detected in album ${deviceAlbum.name}. Updating metadata.",
|
||||
);
|
||||
_log.fine("No asset changes detected in album ${deviceAlbum.name}. Updating metadata.");
|
||||
_localAlbumRepository.upsert(updatedDeviceAlbum);
|
||||
return true;
|
||||
}
|
||||
|
||||
await _localAlbumRepository.upsert(
|
||||
updatedDeviceAlbum,
|
||||
toUpsert: assetsToUpsert,
|
||||
toDelete: assetsToDelete,
|
||||
);
|
||||
await _localAlbumRepository.upsert(updatedDeviceAlbum, toUpsert: assetsToUpsert, toDelete: assetsToDelete);
|
||||
|
||||
return true;
|
||||
} catch (e, s) {
|
||||
@@ -321,9 +271,7 @@ class LocalSyncService {
|
||||
}
|
||||
|
||||
bool _albumsEqual(LocalAlbum a, LocalAlbum b) {
|
||||
return a.name == b.name &&
|
||||
a.assetCount == b.assetCount &&
|
||||
a.updatedAt.isAtSameMomentAs(b.updatedAt);
|
||||
return a.name == b.name && a.assetCount == b.assetCount && a.updatedAt.isAtSameMomentAs(b.updatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,9 +281,7 @@ extension on Iterable<PlatformAlbum> {
|
||||
(e) => LocalAlbum(
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
updatedAt: e.updatedAt == null
|
||||
? DateTime.now()
|
||||
: DateTime.fromMillisecondsSinceEpoch(e.updatedAt! * 1000),
|
||||
updatedAt: tryFromSecondsSinceEpoch(e.updatedAt, isUtc: true) ?? DateTime.timestamp(),
|
||||
assetCount: e.assetCount,
|
||||
),
|
||||
).toList();
|
||||
@@ -350,16 +296,13 @@ extension on Iterable<PlatformAsset> {
|
||||
name: e.name,
|
||||
checksum: null,
|
||||
type: AssetType.values.elementAtOrNull(e.type) ?? AssetType.other,
|
||||
createdAt: e.createdAt == null
|
||||
? DateTime.now()
|
||||
: DateTime.fromMillisecondsSinceEpoch(e.createdAt! * 1000),
|
||||
updatedAt: e.updatedAt == null
|
||||
? DateTime.now()
|
||||
: DateTime.fromMillisecondsSinceEpoch(e.updatedAt! * 1000),
|
||||
createdAt: tryFromSecondsSinceEpoch(e.createdAt, isUtc: true) ?? DateTime.timestamp(),
|
||||
updatedAt: tryFromSecondsSinceEpoch(e.updatedAt, isUtc: true) ?? DateTime.timestamp(),
|
||||
width: e.width,
|
||||
height: e.height,
|
||||
durationInSeconds: e.durationInSeconds,
|
||||
orientation: e.orientation,
|
||||
isFavorite: e.isFavorite,
|
||||
),
|
||||
).toList();
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// Service responsible for handling application logging.
|
||||
@@ -14,8 +14,8 @@ import 'package:logging/logging.dart';
|
||||
/// writes them to a persistent [ILogRepository], and manages log levels
|
||||
/// via [IStoreRepository]
|
||||
class LogService {
|
||||
final IsarLogRepository _logRepository;
|
||||
final IsarStoreRepository _storeRepository;
|
||||
final LogRepository _logRepository;
|
||||
final IStoreRepository _storeRepository;
|
||||
|
||||
final List<LogMessage> _msgBuffer = [];
|
||||
|
||||
@@ -37,8 +37,8 @@ class LogService {
|
||||
}
|
||||
|
||||
static Future<LogService> init({
|
||||
required IsarLogRepository logRepository,
|
||||
required IsarStoreRepository storeRepository,
|
||||
required LogRepository logRepository,
|
||||
required IStoreRepository storeRepository,
|
||||
bool shouldBuffer = true,
|
||||
}) async {
|
||||
_instance ??= await create(
|
||||
@@ -50,34 +50,28 @@ class LogService {
|
||||
}
|
||||
|
||||
static Future<LogService> create({
|
||||
required IsarLogRepository logRepository,
|
||||
required IsarStoreRepository storeRepository,
|
||||
required LogRepository logRepository,
|
||||
required IStoreRepository storeRepository,
|
||||
bool shouldBuffer = true,
|
||||
}) async {
|
||||
final instance = LogService._(logRepository, storeRepository, shouldBuffer);
|
||||
await logRepository.truncate(limit: kLogTruncateLimit);
|
||||
final level = await instance._storeRepository.tryGet(StoreKey.logLevel) ??
|
||||
LogLevel.info.index;
|
||||
final level = await instance._storeRepository.tryGet(StoreKey.logLevel) ?? LogLevel.info.index;
|
||||
Logger.root.level = Level.LEVELS.elementAtOrNull(level) ?? Level.INFO;
|
||||
return instance;
|
||||
}
|
||||
|
||||
LogService._(
|
||||
this._logRepository,
|
||||
this._storeRepository,
|
||||
this._shouldBuffer,
|
||||
) {
|
||||
LogService._(this._logRepository, this._storeRepository, this._shouldBuffer) {
|
||||
_logSubscription = Logger.root.onRecord.listen(_handleLogRecord);
|
||||
}
|
||||
|
||||
void _handleLogRecord(LogRecord r) {
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'[${r.level.name}] [${r.time}] [${r.loggerName}] ${r.message}'
|
||||
'${r.error == null ? '' : '\nError: ${r.error}'}'
|
||||
'${r.stackTrace == null ? '' : '\nStack: ${r.stackTrace}'}',
|
||||
);
|
||||
}
|
||||
dPrint(
|
||||
() =>
|
||||
'[${r.level.name}] [${r.time}] [${r.loggerName}] ${r.message}'
|
||||
'${r.error == null ? '' : '\nError: ${r.error}'}'
|
||||
'${r.stackTrace == null ? '' : '\nStack: ${r.stackTrace}'}',
|
||||
);
|
||||
|
||||
final record = LogMessage(
|
||||
message: r.message,
|
||||
@@ -90,17 +84,14 @@ class LogService {
|
||||
|
||||
if (_shouldBuffer) {
|
||||
_msgBuffer.add(record);
|
||||
_flushTimer ??= Timer(
|
||||
const Duration(seconds: 5),
|
||||
() => unawaited(flushBuffer()),
|
||||
);
|
||||
_flushTimer ??= Timer(const Duration(seconds: 5), () => unawaited(_flushBuffer()));
|
||||
} else {
|
||||
unawaited(_logRepository.insert(record));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setLogLevel(LogLevel level) async {
|
||||
await _storeRepository.insert(StoreKey.logLevel, level.index);
|
||||
await _storeRepository.upsert(StoreKey.logLevel, level.index);
|
||||
Logger.root.level = level.toLevel();
|
||||
}
|
||||
|
||||
@@ -116,23 +107,26 @@ class LogService {
|
||||
await _logRepository.deleteAll();
|
||||
}
|
||||
|
||||
void flush() {
|
||||
Future<void> flush() {
|
||||
_flushTimer?.cancel();
|
||||
// TODO: Rename enable this after moving to sqlite - #16504
|
||||
// await _flushBufferToDatabase();
|
||||
return _flushBuffer();
|
||||
}
|
||||
|
||||
Future<void> dispose() {
|
||||
_flushTimer?.cancel();
|
||||
_logSubscription.cancel();
|
||||
return flushBuffer();
|
||||
return _flushBuffer();
|
||||
}
|
||||
|
||||
// TOOD: Move this to private once Isar is removed
|
||||
Future<void> flushBuffer() async {
|
||||
Future<void> _flushBuffer() async {
|
||||
_flushTimer = null;
|
||||
final buffer = [..._msgBuffer];
|
||||
_msgBuffer.clear();
|
||||
|
||||
if (buffer.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _logRepository.insertAll(buffer);
|
||||
}
|
||||
}
|
||||
@@ -146,9 +140,7 @@ class LoggerUnInitializedException implements Exception {
|
||||
|
||||
/// Log levels according to dart logging [Level]
|
||||
extension LevelDomainToInfraExtension on Level {
|
||||
LogLevel toLogLevel() =>
|
||||
LogLevel.values.elementAtOrNull(Level.LEVELS.indexOf(this)) ??
|
||||
LogLevel.info;
|
||||
LogLevel toLogLevel() => LogLevel.values.elementAtOrNull(Level.LEVELS.indexOf(this)) ?? LogLevel.info;
|
||||
}
|
||||
|
||||
extension on LogLevel {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:immich_mobile/domain/models/map.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
typedef MapMarkerSource = Future<List<Marker>> Function(LatLngBounds? bounds);
|
||||
|
||||
typedef MapQuery = ({MapMarkerSource markerSource});
|
||||
|
||||
class MapFactory {
|
||||
final DriftMapRepository _mapRepository;
|
||||
|
||||
const MapFactory({required DriftMapRepository mapRepository}) : _mapRepository = mapRepository;
|
||||
|
||||
MapService remote(String ownerId) => MapService(_mapRepository.remote(ownerId));
|
||||
}
|
||||
|
||||
class MapService {
|
||||
final MapMarkerSource _markerSource;
|
||||
|
||||
MapService(MapQuery query) : _markerSource = query.markerSource;
|
||||
|
||||
Future<List<Marker>> Function(LatLngBounds? bounds) get getMarkers => _markerSource;
|
||||
}
|
||||
@@ -12,4 +12,12 @@ class DriftMemoryService {
|
||||
Future<List<DriftMemory>> getMemoryLane(String ownerId) {
|
||||
return _repository.getAll(ownerId);
|
||||
}
|
||||
|
||||
Future<DriftMemory?> get(String memoryId) {
|
||||
return _repository.get(memoryId);
|
||||
}
|
||||
|
||||
Future<int> getCount() {
|
||||
return _repository.getCount();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart';
|
||||
import 'package:immich_mobile/repositories/partner_api.repository.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
|
||||
class DriftPartnerService {
|
||||
final DriftPartnerRepository _driftPartnerRepository;
|
||||
final PartnerApiRepository _partnerApiRepository;
|
||||
|
||||
const DriftPartnerService(
|
||||
this._driftPartnerRepository,
|
||||
this._partnerApiRepository,
|
||||
);
|
||||
const DriftPartnerService(this._driftPartnerRepository, this._partnerApiRepository);
|
||||
|
||||
Future<List<PartnerUserDto>> getSharedWith(String userId) {
|
||||
return _driftPartnerRepository.getSharedWith(userId);
|
||||
@@ -20,13 +17,9 @@ class DriftPartnerService {
|
||||
return _driftPartnerRepository.getSharedBy(userId);
|
||||
}
|
||||
|
||||
Future<List<PartnerUserDto>> getAvailablePartners(
|
||||
String currentUserId,
|
||||
) async {
|
||||
final otherUsers =
|
||||
await _driftPartnerRepository.getAvailablePartners(currentUserId);
|
||||
final currentPartners =
|
||||
await _driftPartnerRepository.getSharedBy(currentUserId);
|
||||
Future<List<PartnerUserDto>> getAvailablePartners(String currentUserId) async {
|
||||
final otherUsers = await _driftPartnerRepository.getAvailablePartners(currentUserId);
|
||||
final currentPartners = await _driftPartnerRepository.getSharedBy(currentUserId);
|
||||
final available = otherUsers.where((user) {
|
||||
return !currentPartners.any((partner) => partner.id == user.id);
|
||||
}).toList();
|
||||
@@ -37,14 +30,11 @@ class DriftPartnerService {
|
||||
Future<void> toggleShowInTimeline(String partnerId, String userId) async {
|
||||
final partner = await _driftPartnerRepository.getPartner(partnerId, userId);
|
||||
if (partner == null) {
|
||||
debugPrint("Partner not found: $partnerId for user: $userId");
|
||||
dPrint(() => "Partner not found: $partnerId for user: $userId");
|
||||
return;
|
||||
}
|
||||
|
||||
await _partnerApiRepository.update(
|
||||
partnerId,
|
||||
inTimeline: !partner.inTimeline,
|
||||
);
|
||||
await _partnerApiRepository.update(partnerId, inTimeline: !partner.inTimeline);
|
||||
|
||||
await _driftPartnerRepository.toggleShowInTimeline(partner, userId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:immich_mobile/domain/models/person.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/people.repository.dart';
|
||||
import 'package:immich_mobile/repositories/person_api.repository.dart';
|
||||
|
||||
class DriftPeopleService {
|
||||
final DriftPeopleRepository _repository;
|
||||
final PersonApiRepository _personApiRepository;
|
||||
|
||||
const DriftPeopleService(this._repository, this._personApiRepository);
|
||||
|
||||
Future<List<DriftPerson>> getAssetPeople(String assetId) {
|
||||
return _repository.getAssetPeople(assetId);
|
||||
}
|
||||
|
||||
Future<List<DriftPerson>> getAllPeople() {
|
||||
return _repository.getAllPeople();
|
||||
}
|
||||
|
||||
Future<int> updateName(String personId, String name) async {
|
||||
await _personApiRepository.update(personId, name: name);
|
||||
return _repository.updateName(personId, name);
|
||||
}
|
||||
|
||||
Future<int> updateBrithday(String personId, DateTime birthday) async {
|
||||
await _personApiRepository.update(personId, birthday: birthday);
|
||||
return _repository.updateBirthday(personId, birthday);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user