merge main

This commit is contained in:
Ben McCann
2026-03-29 09:33:19 -07:00
2621 changed files with 246878 additions and 69315 deletions
-3
View File
@@ -1,3 +0,0 @@
{
"flutter": "3.35.7"
}
+1 -4
View File
@@ -55,8 +55,5 @@ default.isar
default.isar.lock
libisar.so
# FVM Version
.fvm/
# Translation file
lib/generated/
lib/generated/
+3 -1
View File
@@ -2,7 +2,9 @@
"dart.flutterSdkPath": ".fvm/versions/3.35.7",
"dart.lineLength": 120,
"[dart]": {
"editor.rulers": [120]
"editor.rulers": [
120
]
},
"search.exclude": {
"**/.fvm": true
+7 -5
View File
@@ -4,10 +4,12 @@ The Immich mobile app is a Flutter-based solution leveraging the Isar Database f
## Setup
1. Setup Flutter toolchain using FVM.
2. Run `flutter pub get` to install the dependencies.
3. Run `make translation` to generate the translation file.
4. Run `fvm flutter run` to start the app.
1. [Install mise](https://mise.jdx.dev/installing-mise.html).
2. Change to the immich directory and trust the mise config with `mise trust`.
3. Install tools with mise: `mise install`.
4. Run `flutter pub get` to install the dependencies.
5. Run `make translation` to generate the translation file.
6. Run `flutter run` to start the app.
## Translation
@@ -29,7 +31,7 @@ dcm analyze lib
```
[DCM](https://dcm.dev/) is a vendor tool that needs to be downloaded manually to run locally.
Immich was provided an open source license.
Immich was provided an open source license.
To use it, it is important that you do not have an active free tier license (can be verified with `dcm license`).
If you have write-access to the Immich repository directly, running dcm in your clone should just work.
If you are working on a clone of a fork, you need to connect to the main Immich repository as remote first:
+2 -1
View File
@@ -9,7 +9,7 @@
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
formatter:
formatter:
page_width: 120
linter:
@@ -33,6 +33,7 @@ linter:
require_trailing_commas: true
unrelated_type_equality_checks: true
prefer_const_constructors: true
always_use_package_imports: true
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+2
View File
@@ -8,3 +8,5 @@ project(native_buffer LANGUAGES C)
add_library(native_buffer SHARED
src/main/cpp/native_buffer.c
)
target_link_libraries(native_buffer jnigraphics)
+10 -1
View File
@@ -3,6 +3,7 @@ plugins {
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
id 'com.google.devtools.ksp'
id 'org.jetbrains.kotlin.plugin.serialization'
id 'org.jetbrains.kotlin.plugin.compose' version '2.0.20' // this version matches your Kotlin version
}
@@ -31,7 +32,7 @@ if (keystorePropertiesFile.exists()) {
android {
compileSdkVersion 35
ndkVersion = "28.1.13356709"
ndkVersion = "28.2.13676358"
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
@@ -48,6 +49,7 @@ android {
}
buildFeatures {
buildConfig true
compose true
}
@@ -80,6 +82,7 @@ android {
release {
signingConfig signingConfigs.release
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
namespace 'app.alextran.immich'
@@ -105,8 +108,13 @@ dependencies {
def serialization_version = '1.8.1'
def compose_version = '1.1.1'
def gson_version = '2.10.1'
def okhttp_version = '4.12.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
implementation 'org.chromium.net:cronet-embedded:143.7445.0'
implementation("androidx.media3:media3-datasource-okhttp:1.9.2")
implementation("androidx.media3:media3-datasource-cronet:1.9.2")
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
implementation "androidx.work:work-runtime-ktx:$work_version"
implementation "androidx.concurrent:concurrent-futures:$concurrent_version"
@@ -127,6 +135,7 @@ dependencies {
implementation "androidx.compose.ui:ui-tooling:$compose_version"
implementation "androidx.compose.material3:material3:1.2.1"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.2"
implementation "com.google.android.material:material:1.12.0"
}
// This is uncommented in F-Droid build script
+9 -1
View File
@@ -36,4 +36,12 @@
##---------------End: proguard configuration for Gson ----------
# Keep all widget model classes and their fields for Gson
-keep class app.alextran.immich.widget.model.** { *; }
-keep class app.alextran.immich.widget.model.** { *; }
##---------------Begin: proguard configuration for ok_http JNI ----------
# The ok_http Dart plugin accesses OkHttp and Okio classes via JNI
# string-based reflection (JClass.forName), which R8 cannot trace.
-keep class okhttp3.** { *; }
-keep class okio.** { *; }
-keep class com.example.ok_http.** { *; }
##---------------End: proguard configuration for ok_http JNI ----------
@@ -27,7 +27,8 @@
<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:allowBackup="false">
android:largeHeap="true" android:enableOnBackInvokedCallback="false" android:allowBackup="false"
android:networkSecurityConfig="@xml/network_security_config">
<profileable android:shell="true" />
@@ -116,7 +117,10 @@
android:pathPrefix="/albums/" />
<data
android:host="my.immich.app"
android:pathPrefix="/memories/" />
android:pathPrefix="/people/" />
<data
android:host="my.immich.app"
android:path="/memory" />
<data
android:host="my.immich.app"
android:pathPrefix="/photos/" />
+33 -21
View File
@@ -1,40 +1,52 @@
#include <jni.h>
#include <stdlib.h>
#include <string.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(
Java_app_alextran_immich_NativeBuffer_allocate(
JNIEnv *env, jclass clazz, jint size) {
void *ptr = malloc(size);
return (jlong) ptr;
}
JNIEXPORT void JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_freeNative(
Java_app_alextran_immich_NativeBuffer_free(
JNIEnv *env, jclass clazz, jlong address) {
free((void *) address);
}
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_NativeBuffer_realloc(
JNIEnv *env, jclass clazz, jlong address, jint size) {
void *ptr = realloc((void *) address, size);
return (jlong) ptr;
}
JNIEXPORT jobject JNICALL
Java_app_alextran_immich_NativeBuffer_wrap(
JNIEnv *env, jclass clazz, jlong address, jint capacity) {
return (*env)->NewDirectByteBuffer(env, (void *) address, capacity);
}
JNIEXPORT void JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_freeNative(
JNIEnv *env, jclass clazz, jlong address) {
free((void *) address);
Java_app_alextran_immich_NativeBuffer_copy(
JNIEnv *env, jclass clazz, jobject buffer, jlong destAddress, jint offset, jint length) {
void *src = (*env)->GetDirectBufferAddress(env, buffer);
if (src != NULL) {
memcpy((void *) destAddress, (char *) src + offset, length);
}
}
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);
}
/**
* Creates a JNI global reference to the given object and returns its address.
* The caller is responsible for deleting the global reference when it's no longer needed.
*/
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_NativeBuffer_createGlobalRef(JNIEnv *env, jobject clazz, jobject obj) {
if (obj == NULL) {
return 0;
}
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);
jobject globalRef = (*env)->NewGlobalRef(env, obj);
return (jlong) globalRef;
}
@@ -143,7 +143,7 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
val mediaUrls = call.argument<List<String>>("mediaUrls")
if (mediaUrls != null) {
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
moveToTrash(mediaUrls, result)
moveToTrash(mediaUrls, result)
} else {
result.error("PERMISSION_DENIED", "Media permission required", null)
}
@@ -155,15 +155,23 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
"restoreFromTrash" -> {
val fileName = call.argument<String>("fileName")
val type = call.argument<Int>("type")
val mediaId = call.argument<String>("mediaId")
if (fileName != null && type != null) {
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
restoreFromTrash(fileName, type, result)
} else {
result.error("PERMISSION_DENIED", "Media permission required", null)
}
} else {
result.error("INVALID_NAME", "The file name is not specified.", null)
}
} else
if (mediaId != null && type != null) {
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
restoreFromTrashById(mediaId, type, result)
} else {
result.error("PERMISSION_DENIED", "Media permission required", null)
}
} else {
result.error("INVALID_PARAMS", "Required params are not specified.", null)
}
}
"requestManageMediaPermission" -> {
@@ -175,6 +183,17 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
}
}
"hasManageMediaPermission" -> {
if (hasManageMediaPermission()) {
Log.i("Manage storage permission", "Permission already granted")
result.success(true)
} else {
result.success(false)
}
}
"manageMediaPermission" -> requestManageMediaPermission(result)
else -> result.notImplemented()
}
}
@@ -224,25 +243,47 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
}
@RequiresApi(Build.VERSION_CODES.R)
private fun toggleTrash(contentUris: List<Uri>, isTrashed: Boolean, result: Result) {
val activity = activityBinding?.activity
val contentResolver = context?.contentResolver
if (activity == null || contentResolver == null) {
result.error("TrashError", "Activity or ContentResolver not available", null)
return
}
private fun restoreFromTrashById(mediaId: String, type: Int, result: Result) {
val id = mediaId.toLongOrNull()
if (id == null) {
result.error("INVALID_ID", "The file id is not a valid number: $mediaId", null)
return
}
if (!isInTrash(id)) {
result.error("TrashNotFound", "Item with id=$id not found in trash", null)
return
}
try {
val pendingIntent = MediaStore.createTrashRequest(contentResolver, contentUris, isTrashed)
pendingResult = result // Store for onActivityResult
activity.startIntentSenderForResult(
pendingIntent.intentSender,
trashRequestCode,
null, 0, 0, 0
)
} catch (e: Exception) {
Log.e("TrashError", "Error creating or starting trash request", e)
result.error("TrashError", "Error creating or starting trash request", null)
val uri = ContentUris.withAppendedId(contentUriForType(type), id)
try {
Log.i(TAG, "restoreFromTrashById: uri=$uri (type=$type,id=$id)")
restoreUris(listOf(uri), result)
} catch (e: Exception) {
Log.w(TAG, "restoreFromTrashById failed", e)
}
}
@RequiresApi(Build.VERSION_CODES.R)
private fun toggleTrash(contentUris: List<Uri>, isTrashed: Boolean, result: Result) {
val activity = activityBinding?.activity
val contentResolver = context?.contentResolver
if (activity == null || contentResolver == null) {
result.error("TrashError", "Activity or ContentResolver not available", null)
return
}
try {
val pendingIntent = MediaStore.createTrashRequest(contentResolver, contentUris, isTrashed)
pendingResult = result // Store for onActivityResult
activity.startIntentSenderForResult(
pendingIntent.intentSender,
trashRequestCode,
null, 0, 0, 0
)
} catch (e: Exception) {
Log.e("TrashError", "Error creating or starting trash request", e)
result.error("TrashError", "Error creating or starting trash request", null)
}
}
@@ -264,14 +305,7 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
contentResolver.query(queryUri, projection, queryArgs, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID))
// same order as AssetType from dart
val contentUri = when (type) {
1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
else -> queryUri
}
return ContentUris.withAppendedId(contentUri, id)
return ContentUris.withAppendedId(contentUriForType(type), id)
}
}
return null
@@ -315,6 +349,40 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
}
return false
}
@RequiresApi(Build.VERSION_CODES.R)
private fun isInTrash(id: Long): Boolean {
val contentResolver = context?.contentResolver ?: return false
val filesUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
val args = Bundle().apply {
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Files.FileColumns._ID}=?")
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(id.toString()))
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
putInt(ContentResolver.QUERY_ARG_LIMIT, 1)
}
return contentResolver.query(filesUri, arrayOf(MediaStore.Files.FileColumns._ID), args, null)
?.use { it.moveToFirst() } == true
}
@RequiresApi(Build.VERSION_CODES.R)
private fun restoreUris(uris: List<Uri>, result: Result) {
if (uris.isEmpty()) {
result.error("TrashError", "No URIs to restore", null)
return
}
Log.i(TAG, "restoreUris: count=${uris.size}, first=${uris.first()}")
toggleTrash(uris, false, result)
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun contentUriForType(type: Int): Uri =
when (type) {
// same order as AssetType from dart
1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
else -> MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
}
}
private const val TAG = "BackgroundServicePlugin"
@@ -1,146 +0,0 @@
package app.alextran.immich
import android.annotation.SuppressLint
import android.content.Context
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.ByteArrayInputStream
import java.net.InetSocketAddress
import java.net.Socket
import java.security.KeyStore
import java.security.cert.X509Certificate
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.KeyManager
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLSession
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509ExtendedTrustManager
/**
* Android plugin for Dart `HttpSSLOptions`
*/
class HttpSSLOptionsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private var methodChannel: MethodChannel? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
}
private fun onAttachedToEngine(ctx: Context, messenger: BinaryMessenger) {
methodChannel = MethodChannel(messenger, "immich/httpSSLOptions")
methodChannel?.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
onDetachedFromEngine()
}
private fun onDetachedFromEngine() {
methodChannel?.setMethodCallHandler(null)
methodChannel = null
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
try {
when (call.method) {
"apply" -> {
val args = call.arguments<ArrayList<*>>()!!
var tm: Array<TrustManager>? = null
if (args[0] as Boolean) {
tm = arrayOf(AllowSelfSignedTrustManager(args[1] as? String))
}
var km: Array<KeyManager>? = null
if (args[2] != null) {
val cert = ByteArrayInputStream(args[2] as ByteArray)
val password = (args[3] as String).toCharArray()
val keyStore = KeyStore.getInstance("PKCS12")
keyStore.load(cert, password)
val keyManagerFactory =
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
keyManagerFactory.init(keyStore, null)
km = keyManagerFactory.keyManagers
}
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(km, tm, null)
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
HttpsURLConnection.setDefaultHostnameVerifier(AllowSelfSignedHostnameVerifier(args[1] as? String))
result.success(true)
}
else -> result.notImplemented()
}
} catch (e: Throwable) {
result.error("error", e.message, null)
}
}
@SuppressLint("CustomX509TrustManager")
class AllowSelfSignedTrustManager(private val serverHost: String?) : X509ExtendedTrustManager() {
private val defaultTrustManager: X509ExtendedTrustManager = getDefaultTrustManager()
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) =
defaultTrustManager.checkClientTrusted(chain, authType)
override fun checkClientTrusted(
chain: Array<out X509Certificate>?, authType: String?, socket: Socket?
) = defaultTrustManager.checkClientTrusted(chain, authType, socket)
override fun checkClientTrusted(
chain: Array<out X509Certificate>?, authType: String?, engine: SSLEngine?
) = defaultTrustManager.checkClientTrusted(chain, authType, engine)
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
if (serverHost == null) return
defaultTrustManager.checkServerTrusted(chain, authType)
}
override fun checkServerTrusted(
chain: Array<out X509Certificate>?, authType: String?, socket: Socket?
) {
if (serverHost == null) return
val socketAddress = socket?.remoteSocketAddress
if (socketAddress is InetSocketAddress && socketAddress.hostName == serverHost) return
defaultTrustManager.checkServerTrusted(chain, authType, socket)
}
override fun checkServerTrusted(
chain: Array<out X509Certificate>?, authType: String?, engine: SSLEngine?
) {
if (serverHost == null || engine?.peerHost == serverHost) return
defaultTrustManager.checkServerTrusted(chain, authType, engine)
}
override fun getAcceptedIssuers(): Array<X509Certificate> = defaultTrustManager.acceptedIssuers
private fun getDefaultTrustManager(): X509ExtendedTrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
return factory.trustManagers.filterIsInstance<X509ExtendedTrustManager>().first()
}
}
class AllowSelfSignedHostnameVerifier(private val serverHost: String?) : HostnameVerifier {
companion object {
private val _defaultHostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
}
override fun verify(hostname: String?, session: SSLSession?): Boolean {
if (serverHost == null || hostname == serverHost) {
return true
} else {
return _defaultHostnameVerifier.verify(hostname, session)
}
}
}
}
@@ -9,9 +9,14 @@ 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.core.HttpClientManager
import app.alextran.immich.core.ImmichPlugin
import app.alextran.immich.images.ThumbnailApi
import app.alextran.immich.images.ThumbnailsImpl
import app.alextran.immich.core.NetworkApiPlugin
import me.albemala.native_video_player.NativeVideoPlayerPlugin
import app.alextran.immich.images.LocalImageApi
import app.alextran.immich.images.LocalImagesImpl
import app.alextran.immich.images.RemoteImageApi
import app.alextran.immich.images.RemoteImagesImpl
import app.alextran.immich.sync.NativeSyncApi
import app.alextran.immich.sync.NativeSyncApiImpl26
import app.alextran.immich.sync.NativeSyncApiImpl30
@@ -26,6 +31,10 @@ class MainActivity : FlutterFragmentActivity() {
companion object {
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
HttpClientManager.initialize(ctx)
NativeVideoPlayerPlugin.dataSourceFactory = HttpClientManager::createDataSourceFactory
flutterEngine.plugins.add(NetworkApiPlugin())
val messenger = flutterEngine.dartExecutor.binaryMessenger
val backgroundEngineLockImpl = BackgroundEngineLock(ctx)
BackgroundWorkerLockApi.setUp(messenger, backgroundEngineLockImpl)
@@ -36,12 +45,13 @@ class MainActivity : FlutterFragmentActivity() {
NativeSyncApiImpl30(ctx)
}
NativeSyncApi.setUp(messenger, nativeSyncApiImpl)
ThumbnailApi.setUp(messenger, ThumbnailsImpl(ctx))
LocalImageApi.setUp(messenger, LocalImagesImpl(ctx))
RemoteImageApi.setUp(messenger, RemoteImagesImpl(ctx))
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
flutterEngine.plugins.add(BackgroundServicePlugin())
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
flutterEngine.plugins.add(backgroundEngineLockImpl)
flutterEngine.plugins.add(nativeSyncApiImpl)
}
@@ -0,0 +1,55 @@
package app.alextran.immich
import java.nio.ByteBuffer
const val INITIAL_BUFFER_SIZE = 32 * 1024
object NativeBuffer {
init {
System.loadLibrary("native_buffer")
}
@JvmStatic
external fun allocate(size: Int): Long
@JvmStatic
external fun free(address: Long)
@JvmStatic
external fun realloc(address: Long, size: Int): Long
@JvmStatic
external fun wrap(address: Long, capacity: Int): ByteBuffer
@JvmStatic
external fun copy(buffer: ByteBuffer, destAddress: Long, offset: Int, length: Int)
@JvmStatic
external fun createGlobalRef(obj: Any): Long
}
class NativeByteBuffer(initialCapacity: Int) {
var pointer = NativeBuffer.allocate(initialCapacity)
var capacity = initialCapacity
var offset = 0
inline fun ensureHeadroom() {
if (offset == capacity) {
capacity *= 2
pointer = NativeBuffer.realloc(pointer, capacity)
}
}
inline fun wrapRemaining() = NativeBuffer.wrap(pointer + offset, capacity - offset)
inline fun advance(bytesRead: Int) {
offset += bytesRead
}
inline fun free() {
if (pointer != 0L) {
NativeBuffer.free(pointer)
pointer = 0L
}
}
}
@@ -0,0 +1,552 @@
package app.alextran.immich.core
import android.content.Context
import android.content.SharedPreferences
import android.security.KeyChain
import androidx.annotation.OptIn
import androidx.core.content.edit
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.ResolvingDataSource
import androidx.media3.datasource.cronet.CronetDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import app.alextran.immich.BuildConfig
import app.alextran.immich.NativeBuffer
import okhttp3.Cache
import okhttp3.ConnectionPool
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.Credentials
import okhttp3.Dispatcher
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import org.chromium.net.CronetEngine
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream
import java.io.File
import java.io.IOException
import java.nio.file.FileVisitResult
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
import java.net.Authenticator
import java.net.CookieHandler
import java.net.PasswordAuthentication
import java.net.Socket
import java.net.URI
import java.security.KeyStore
import java.security.Principal
import java.security.PrivateKey
import java.security.cert.X509Certificate
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509KeyManager
import javax.net.ssl.X509TrustManager
const val USER_AGENT = "Immich_Android_${BuildConfig.VERSION_NAME}"
private const val CERT_ALIAS = "client_cert"
private const val PREFS_NAME = "immich.ssl"
private const val PREFS_CERT_ALIAS = "immich.client_cert"
private const val PREFS_HEADERS = "immich.request_headers"
private const val PREFS_SERVER_URLS = "immich.server_urls"
private const val PREFS_COOKIES = "immich.cookies"
private const val COOKIE_EXPIRY_DAYS = 400L
private enum class AuthCookie(val cookieName: String, val httpOnly: Boolean) {
ACCESS_TOKEN("immich_access_token", httpOnly = true),
IS_AUTHENTICATED("immich_is_authenticated", httpOnly = false),
AUTH_TYPE("immich_auth_type", httpOnly = true);
companion object {
val names = entries.map { it.cookieName }.toSet()
}
}
/**
* Manages a shared OkHttpClient with SSL configuration support.
*/
object HttpClientManager {
private const val CACHE_SIZE_BYTES = 100L * 1024 * 1024 // 100MiB
const val MEDIA_CACHE_SIZE_BYTES = 1024L * 1024 * 1024 // 1GiB
private const val KEEP_ALIVE_CONNECTIONS = 10
private const val KEEP_ALIVE_DURATION_MINUTES = 5L
private const val MAX_REQUESTS_PER_HOST = 64
private var initialized = false
private val clientChangedListeners = mutableListOf<() -> Unit>()
private lateinit var client: OkHttpClient
private lateinit var appContext: Context
private lateinit var prefs: SharedPreferences
var cronetEngine: CronetEngine? = null
private set
private lateinit var cronetStorageDir: File
val cronetExecutor: ExecutorService = Executors.newFixedThreadPool(4)
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
var keyChainAlias: String? = null
private set
var headers: Headers = Headers.headersOf()
private set
private val cookieJar = PersistentCookieJar()
val isMtls: Boolean get() = keyChainAlias != null || keyStore.containsAlias(CERT_ALIAS)
fun initialize(context: Context) {
if (initialized) return
synchronized(this) {
if (initialized) return
appContext = context.applicationContext
prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
keyChainAlias = prefs.getString(PREFS_CERT_ALIAS, null)
cookieJar.init(prefs)
System.setProperty("http.agent", USER_AGENT)
Authenticator.setDefault(object : Authenticator() {
override fun getPasswordAuthentication(): PasswordAuthentication? {
val url = requestingURL ?: return null
if (url.userInfo.isNullOrEmpty()) return null
val parts = url.userInfo.split(":", limit = 2)
return PasswordAuthentication(parts[0], parts.getOrElse(1) { "" }.toCharArray())
}
})
CookieHandler.setDefault(object : CookieHandler() {
override fun get(uri: URI, requestHeaders: Map<String, List<String>>): Map<String, List<String>> {
val httpUrl = uri.toString().toHttpUrlOrNull() ?: return emptyMap()
val cookies = cookieJar.loadForRequest(httpUrl)
if (cookies.isEmpty()) return emptyMap()
return mapOf("Cookie" to listOf(cookies.joinToString("; ") { "${it.name}=${it.value}" }))
}
override fun put(uri: URI, responseHeaders: Map<String, List<String>>) {}
})
val savedHeaders = prefs.getString(PREFS_HEADERS, null)
if (savedHeaders != null) {
val map = Json.decodeFromString<Map<String, String>>(savedHeaders)
val builder = Headers.Builder()
for ((key, value) in map) {
builder.add(key, value)
}
headers = builder.build()
}
val serverUrlsJson = prefs.getString(PREFS_SERVER_URLS, null)
if (serverUrlsJson != null) {
cookieJar.setServerUrls(Json.decodeFromString<List<String>>(serverUrlsJson))
}
val cacheDir = File(File(context.cacheDir, "okhttp"), "api")
client = build(cacheDir)
cronetStorageDir = File(context.cacheDir, "cronet").apply { mkdirs() }
cronetEngine = buildCronetEngine()
initialized = true
}
}
fun setKeyChainAlias(alias: String) {
synchronized(this) {
val wasMtls = isMtls
keyChainAlias = alias
prefs.edit { putString(PREFS_CERT_ALIAS, alias) }
if (wasMtls != isMtls) {
clientChangedListeners.forEach { it() }
}
}
}
fun setKeyEntry(clientData: ByteArray, password: CharArray) {
synchronized(this) {
val wasMtls = isMtls
val tmpKeyStore = KeyStore.getInstance("PKCS12").apply {
ByteArrayInputStream(clientData).use { stream -> load(stream, password) }
}
val tmpAlias = tmpKeyStore.aliases().asSequence().firstOrNull { tmpKeyStore.isKeyEntry(it) }
?: throw IllegalArgumentException("No private key found in PKCS12")
val key = tmpKeyStore.getKey(tmpAlias, password)
val chain = tmpKeyStore.getCertificateChain(tmpAlias)
if (keyStore.containsAlias(CERT_ALIAS)) {
keyStore.deleteEntry(CERT_ALIAS)
}
keyStore.setKeyEntry(CERT_ALIAS, key, null, chain)
if (wasMtls != isMtls) {
clientChangedListeners.forEach { it() }
}
}
}
fun deleteKeyEntry() {
synchronized(this) {
val wasMtls = isMtls
if (keyChainAlias != null) {
keyChainAlias = null
prefs.edit { remove(PREFS_CERT_ALIAS) }
}
keyStore.deleteEntry(CERT_ALIAS)
if (wasMtls) {
clientChangedListeners.forEach { it() }
}
}
}
private var clientGlobalRef: Long = 0L
@JvmStatic
fun getClient(): OkHttpClient {
return client
}
fun getClientPointer(): Long {
if (clientGlobalRef == 0L) {
clientGlobalRef = NativeBuffer.createGlobalRef(client)
}
return clientGlobalRef
}
fun addClientChangedListener(listener: () -> Unit) {
synchronized(this) { clientChangedListeners.add(listener) }
}
fun setRequestHeaders(headerMap: Map<String, String>, serverUrls: List<String>, token: String?) {
synchronized(this) {
val builder = Headers.Builder()
headerMap.forEach { (key, value) -> builder[key] = value }
val newHeaders = builder.build()
val headersChanged = headers != newHeaders
val urlsChanged = Json.encodeToString(serverUrls) != prefs.getString(PREFS_SERVER_URLS, null)
headers = newHeaders
cookieJar.setServerUrls(serverUrls)
if (headersChanged || urlsChanged) {
prefs.edit {
putString(PREFS_HEADERS, Json.encodeToString(headerMap))
putString(PREFS_SERVER_URLS, Json.encodeToString(serverUrls))
}
}
if (token != null) {
val url = serverUrls.firstNotNullOfOrNull { it.toHttpUrlOrNull() } ?: return
val expiry = System.currentTimeMillis() + COOKIE_EXPIRY_DAYS * 24 * 60 * 60 * 1000
val values = mapOf(
AuthCookie.ACCESS_TOKEN to token,
AuthCookie.IS_AUTHENTICATED to "true",
AuthCookie.AUTH_TYPE to "password",
)
cookieJar.saveFromResponse(url, values.map { (cookie, value) ->
Cookie.Builder().name(cookie.cookieName).value(value).domain(url.host).path("/").expiresAt(expiry)
.apply {
if (url.isHttps) secure()
if (cookie.httpOnly) httpOnly()
}.build()
})
}
}
}
fun loadCookieHeader(url: String): String? {
val httpUrl = url.toHttpUrlOrNull() ?: return null
return cookieJar.loadForRequest(httpUrl).takeIf { it.isNotEmpty() }
?.joinToString("; ") { "${it.name}=${it.value}" }
}
fun getAuthHeaders(url: String): Map<String, String> {
val result = mutableMapOf<String, String>()
headers.forEach { (key, value) -> result[key] = value }
loadCookieHeader(url)?.let { result["Cookie"] = it }
url.toHttpUrlOrNull()?.let { httpUrl ->
if (httpUrl.username.isNotEmpty()) {
result["Authorization"] = Credentials.basic(httpUrl.username, httpUrl.password)
}
}
return result
}
suspend fun rebuildCronetEngine(): Result<Long> {
return runCatching {
cronetEngine?.shutdown()
val deletionResult = deleteFolderAndGetSize(cronetStoragePath.toPath())
cronetEngine = buildCronetEngine()
deletionResult
}
}
val cronetStoragePath: File get() = cronetStorageDir
@OptIn(UnstableApi::class)
fun createDataSourceFactory(headers: Map<String, String>): DataSource.Factory {
return if (isMtls) {
OkHttpDataSource.Factory(client.newBuilder().cache(null).build())
} else {
ResolvingDataSource.Factory(
CronetDataSource.Factory(cronetEngine!!, cronetExecutor)
) { dataSpec ->
val newHeaders = dataSpec.httpRequestHeaders.toMutableMap()
newHeaders.putAll(getAuthHeaders(dataSpec.uri.toString()))
newHeaders["Cache-Control"] = "no-store"
dataSpec.buildUpon().setHttpRequestHeaders(newHeaders).build()
}
}
}
fun buildCronetEngine(): CronetEngine {
return CronetEngine.Builder(appContext)
.enableHttp2(true)
.enableQuic(true)
.enableBrotli(true)
.setStoragePath(cronetStorageDir.absolutePath)
.setUserAgent(USER_AGENT)
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, MEDIA_CACHE_SIZE_BYTES)
.build()
}
private suspend fun deleteFolderAndGetSize(root: Path): Long = withContext(Dispatchers.IO) {
var totalSize = 0L
Files.walkFileTree(root, object : SimpleFileVisitor<Path>() {
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
totalSize += attrs.size()
Files.delete(file)
return FileVisitResult.CONTINUE
}
override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
if (dir != root) {
Files.delete(dir)
}
return FileVisitResult.CONTINUE
}
})
totalSize
}
private fun build(cacheDir: File): OkHttpClient {
val connectionPool = ConnectionPool(
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
keepAliveDuration = KEEP_ALIVE_DURATION_MINUTES,
timeUnit = TimeUnit.MINUTES
)
val managerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
managerFactory.init(null as KeyStore?)
val trustManager = managerFactory.trustManagers.filterIsInstance<X509TrustManager>().first()
val sslContext = SSLContext.getInstance("TLS")
.apply { init(arrayOf(DynamicKeyManager()), arrayOf(trustManager), null) }
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
return OkHttpClient.Builder()
.cookieJar(cookieJar)
.addInterceptor {
val request = it.request()
val builder = request.newBuilder()
builder.header("User-Agent", USER_AGENT)
headers.forEach { (key, value) -> builder.header(key, value) }
val url = request.url
if (url.username.isNotEmpty()) {
builder.header("Authorization", Credentials.basic(url.username, url.password))
}
it.proceed(builder.build())
}
.connectionPool(connectionPool)
.dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST })
.cache(Cache(cacheDir.apply { mkdirs() }, CACHE_SIZE_BYTES))
.sslSocketFactory(sslContext.socketFactory, trustManager)
.build()
}
/**
* Resolves client certificates dynamically at TLS handshake time.
* Checks the system KeyChain alias first, then falls back to the app's private KeyStore.
*/
private class DynamicKeyManager : X509KeyManager {
override fun getClientAliases(keyType: String, issuers: Array<Principal>?): Array<String>? {
val alias = chooseClientAlias(arrayOf(keyType), issuers, null) ?: return null
return arrayOf(alias)
}
override fun chooseClientAlias(
keyTypes: Array<String>,
issuers: Array<Principal>?,
socket: Socket?
): String? {
keyChainAlias?.let { return it }
if (keyStore.containsAlias(CERT_ALIAS)) return CERT_ALIAS
return null
}
override fun getCertificateChain(alias: String): Array<X509Certificate>? {
if (alias == keyChainAlias) {
return KeyChain.getCertificateChain(appContext, alias)
}
return keyStore.getCertificateChain(alias)?.map { it as X509Certificate }?.toTypedArray()
}
override fun getPrivateKey(alias: String): PrivateKey? {
if (alias == keyChainAlias) {
return KeyChain.getPrivateKey(appContext, alias)
}
return keyStore.getKey(alias, null) as? PrivateKey
}
override fun getServerAliases(keyType: String, issuers: Array<Principal>?): Array<String>? =
null
override fun chooseServerAlias(
keyType: String,
issuers: Array<Principal>?,
socket: Socket?
): String? = null
}
/**
* Persistent CookieJar that duplicates auth cookies across equivalent server URLs.
* When the server sets cookies for one domain, copies are created for all other known
* server domains (for URL switching between local/remote endpoints of the same server).
*/
private class PersistentCookieJar : CookieJar {
private val store = mutableListOf<Cookie>()
private var serverUrls = listOf<HttpUrl>()
private var prefs: SharedPreferences? = null
fun init(prefs: SharedPreferences) {
this.prefs = prefs
restore()
}
@Synchronized
fun setServerUrls(urls: List<String>) {
val parsed = urls.mapNotNull { it.toHttpUrlOrNull() }
if (parsed.map { it.host } == serverUrls.map { it.host }) return
serverUrls = parsed
if (syncAuthCookies()) persist()
}
@Synchronized
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val changed = cookies.any { new ->
store.none { it.name == new.name && it.domain == new.domain && it.path == new.path && it.value == new.value }
}
store.removeAll { existing ->
cookies.any { it.name == existing.name && it.domain == existing.domain && it.path == existing.path }
}
store.addAll(cookies)
val synced = serverUrls.any { it.host == url.host } && syncAuthCookies()
if (changed || synced) persist()
}
@Synchronized
override fun loadForRequest(url: HttpUrl): List<Cookie> {
val now = System.currentTimeMillis()
if (store.removeAll { it.expiresAt < now }) {
syncAuthCookies()
persist()
}
return store.filter { it.matches(url) }
}
private fun syncAuthCookies(): Boolean {
val serverHosts = serverUrls.map { it.host }.toSet()
val now = System.currentTimeMillis()
val sourceCookies = store
.filter { it.name in AuthCookie.names && it.domain in serverHosts && it.expiresAt > now }
.associateBy { it.name }
if (sourceCookies.isEmpty()) {
return store.removeAll { it.name in AuthCookie.names && it.domain in serverHosts }
}
var changed = false
for (url in serverUrls) {
for ((_, source) in sourceCookies) {
if (store.any { it.name == source.name && it.domain == url.host && it.value == source.value }) continue
store.removeAll { it.name == source.name && it.domain == url.host }
store.add(rebuildCookie(source, url))
changed = true
}
}
return changed
}
private fun rebuildCookie(source: Cookie, url: HttpUrl): Cookie {
return Cookie.Builder()
.name(source.name).value(source.value)
.domain(url.host).path("/")
.expiresAt(source.expiresAt)
.apply {
if (url.isHttps) secure()
if (source.httpOnly) httpOnly()
}
.build()
}
private fun persist() {
val p = prefs ?: return
p.edit { putString(PREFS_COOKIES, Json.encodeToString(store.map { SerializedCookie.from(it) })) }
}
private fun restore() {
val p = prefs ?: return
val jsonStr = p.getString(PREFS_COOKIES, null) ?: return
try {
store.addAll(Json.decodeFromString<List<SerializedCookie>>(jsonStr).map { it.toCookie() })
} catch (_: Exception) {
store.clear()
}
}
}
@Serializable
private data class SerializedCookie(
val name: String,
val value: String,
val domain: String,
val path: String,
val expiresAt: Long,
val secure: Boolean,
val httpOnly: Boolean,
val hostOnly: Boolean,
) {
fun toCookie(): Cookie = Cookie.Builder()
.name(name).value(value).path(path).expiresAt(expiresAt)
.apply {
if (hostOnly) hostOnlyDomain(domain) else domain(domain)
if (secure) secure()
if (httpOnly) httpOnly()
}
.build()
companion object {
fun from(cookie: Cookie) = SerializedCookie(
name = cookie.name, value = cookie.value, domain = cookie.domain,
path = cookie.path, expiresAt = cookie.expiresAt, secure = cookie.secure,
httpOnly = cookie.httpOnly, hostOnly = cookie.hostOnly,
)
}
}
}
@@ -0,0 +1,305 @@
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
package app.alextran.immich.core
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 NetworkPigeonUtils {
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 ClientCertData (
val data: ByteArray,
val password: String
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): ClientCertData {
val data = pigeonVar_list[0] as ByteArray
val password = pigeonVar_list[1] as String
return ClientCertData(data, password)
}
}
fun toList(): List<Any?> {
return listOf(
data,
password,
)
}
override fun equals(other: Any?): Boolean {
if (other !is ClientCertData) {
return false
}
if (this === other) {
return true
}
return NetworkPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
/** Generated class from Pigeon that represents data sent in messages. */
data class ClientCertPrompt (
val title: String,
val message: String,
val cancel: String,
val confirm: String
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): ClientCertPrompt {
val title = pigeonVar_list[0] as String
val message = pigeonVar_list[1] as String
val cancel = pigeonVar_list[2] as String
val confirm = pigeonVar_list[3] as String
return ClientCertPrompt(title, message, cancel, confirm)
}
}
fun toList(): List<Any?> {
return listOf(
title,
message,
cancel,
confirm,
)
}
override fun equals(other: Any?): Boolean {
if (other !is ClientCertPrompt) {
return false
}
if (this === other) {
return true
}
return NetworkPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
private open class NetworkPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
129.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
ClientCertData.fromList(it)
}
}
130.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
ClientCertPrompt.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) {
is ClientCertData -> {
stream.write(129)
writeValue(stream, value.toList())
}
is ClientCertPrompt -> {
stream.write(130)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface NetworkApi {
fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit)
fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<Unit>) -> Unit)
fun removeCertificate(callback: (Result<Unit>) -> Unit)
fun hasCertificate(): Boolean
fun getClientPointer(): Long
fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>, token: String?)
companion object {
/** The codec used by NetworkApi. */
val codec: MessageCodec<Any?> by lazy {
NetworkPigeonCodec()
}
/** Sets up an instance of `NetworkApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: NetworkApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.addCertificate$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val clientDataArg = args[0] as ClientCertData
api.addCertificate(clientDataArg) { result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(NetworkPigeonUtils.wrapError(error))
} else {
reply.reply(NetworkPigeonUtils.wrapResult(null))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.selectCertificate$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val promptTextArg = args[0] as ClientCertPrompt
api.selectCertificate(promptTextArg) { result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(NetworkPigeonUtils.wrapError(error))
} else {
reply.reply(NetworkPigeonUtils.wrapResult(null))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.removeCertificate$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.removeCertificate{ result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(NetworkPigeonUtils.wrapError(error))
} else {
reply.reply(NetworkPigeonUtils.wrapResult(null))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.hasCertificate$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.hasCertificate())
} catch (exception: Throwable) {
NetworkPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.getClientPointer())
} catch (exception: Throwable) {
NetworkPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val headersArg = args[0] as Map<String, String>
val serverUrlsArg = args[1] as List<String>
val tokenArg = args[2] as String?
val wrapped: List<Any?> = try {
api.setRequestHeaders(headersArg, serverUrlsArg, tokenArg)
listOf(null)
} catch (exception: Throwable) {
NetworkPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
@@ -0,0 +1,85 @@
package app.alextran.immich.core
import android.app.Activity
import android.content.Context
import android.os.OperationCanceledException
import android.security.KeyChain
import app.alextran.immich.NativeBuffer
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
class NetworkApiPlugin : FlutterPlugin, ActivityAware {
private var networkApi: NetworkApiImpl? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
networkApi = NetworkApiImpl()
NetworkApi.setUp(binding.binaryMessenger, networkApi)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
NetworkApi.setUp(binding.binaryMessenger, null)
networkApi = null
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
networkApi?.activity = binding.activity
}
override fun onDetachedFromActivityForConfigChanges() {
networkApi?.activity = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
networkApi?.activity = binding.activity
}
override fun onDetachedFromActivity() {
networkApi?.activity = null
}
}
private class NetworkApiImpl : NetworkApi {
var activity: Activity? = null
override fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit) {
try {
HttpClientManager.setKeyEntry(clientData.data, clientData.password.toCharArray())
callback(Result.success(Unit))
} catch (e: Exception) {
callback(Result.failure(e))
}
}
override fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<Unit>) -> Unit) {
val currentActivity = activity
?: return callback(Result.failure(IllegalStateException("No activity")))
val onAlias = { alias: String? ->
if (alias != null) {
HttpClientManager.setKeyChainAlias(alias)
callback(Result.success(Unit))
} else {
callback(Result.failure(OperationCanceledException()))
}
}
KeyChain.choosePrivateKeyAlias(currentActivity, onAlias, null, null, null, null)
}
override fun removeCertificate(callback: (Result<Unit>) -> Unit) {
HttpClientManager.deleteKeyEntry()
callback(Result.success(Unit))
}
override fun hasCertificate(): Boolean {
return HttpClientManager.isMtls
}
override fun getClientPointer(): Long {
return HttpClientManager.getClientPointer()
}
override fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>, token: String?) {
HttpClientManager.setRequestHeaders(headers, serverUrls, token)
}
}
@@ -13,7 +13,7 @@ import io.flutter.plugin.common.StandardMethodCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private object ThumbnailsPigeonUtils {
private object LocalImagesPigeonUtils {
fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
@@ -47,7 +47,7 @@ class FlutterError (
override val message: String? = null,
val details: Any? = null
) : Throwable()
private open class ThumbnailsPigeonCodec : StandardMessageCodec() {
private open class LocalImagesPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return super.readValueOfType(type, buffer)
}
@@ -58,22 +58,22 @@ private open class ThumbnailsPigeonCodec : StandardMessageCodec() {
/** 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)
interface LocalImageApi {
fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, isVideo: Boolean, preferEncoded: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
fun cancelRequest(requestId: Long)
fun getThumbhash(thumbhash: String, callback: (Result<Map<String, Long>>) -> Unit)
companion object {
/** The codec used by ThumbnailApi. */
/** The codec used by LocalImageApi. */
val codec: MessageCodec<Any?> by lazy {
ThumbnailsPigeonCodec()
LocalImagesPigeonCodec()
}
/** Sets up an instance of `ThumbnailApi` to handle messages through the `binaryMessenger`. */
/** Sets up an instance of `LocalImageApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: ThumbnailApi?, messageChannelSuffix: String = "") {
fun setUp(binaryMessenger: BinaryMessenger, api: LocalImageApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ThumbnailApi.requestImage$separatedMessageChannelSuffix", codec)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
@@ -82,13 +82,14 @@ interface ThumbnailApi {
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 preferEncodedArg = args[5] as Boolean
api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg, preferEncodedArg) { result: Result<Map<String, Long>?> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(ThumbnailsPigeonUtils.wrapError(error))
reply.reply(LocalImagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(ThumbnailsPigeonUtils.wrapResult(data))
reply.reply(LocalImagesPigeonUtils.wrapResult(data))
}
}
}
@@ -97,16 +98,16 @@ interface ThumbnailApi {
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ThumbnailApi.cancelImageRequest$separatedMessageChannelSuffix", codec)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.LocalImageApi.cancelRequest$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)
api.cancelRequest(requestIdArg)
listOf(null)
} catch (exception: Throwable) {
ThumbnailsPigeonUtils.wrapError(exception)
LocalImagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
@@ -115,7 +116,7 @@ interface ThumbnailApi {
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ThumbnailApi.getThumbhash$separatedMessageChannelSuffix", codec)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.LocalImageApi.getThumbhash$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
@@ -123,10 +124,10 @@ interface ThumbnailApi {
api.getThumbhash(thumbhashArg) { result: Result<Map<String, Long>> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(ThumbnailsPigeonUtils.wrapError(error))
reply.reply(LocalImagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(ThumbnailsPigeonUtils.wrapResult(data))
reply.reply(LocalImagesPigeonUtils.wrapResult(data))
}
}
}
@@ -11,8 +11,10 @@ import android.os.OperationCanceledException
import android.provider.MediaStore.Images
import android.provider.MediaStore.Video
import android.util.Size
import java.nio.ByteBuffer
import androidx.annotation.RequiresApi
import app.alextran.immich.NativeBuffer
import kotlin.math.*
import java.io.IOException
import java.util.concurrent.Executors
import com.bumptech.glide.Glide
import com.bumptech.glide.Priority
@@ -26,10 +28,42 @@ import java.util.concurrent.Future
data class Request(
val taskFuture: Future<*>,
val cancellationSignal: CancellationSignal,
val callback: (Result<Map<String, Long>>) -> Unit
val callback: (Result<Map<String, Long>?>) -> Unit
)
class ThumbnailsImpl(context: Context) : ThumbnailApi {
@RequiresApi(Build.VERSION_CODES.Q)
inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0)): Bitmap {
return ImageDecoder.decodeBitmap(this) { 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))
}
}
fun Bitmap.toNativeBuffer(): Map<String, Long> {
val size = width * height * 4
val pointer = NativeBuffer.allocate(size)
try {
val buffer = NativeBuffer.wrap(pointer, size)
copyPixelsToBuffer(buffer)
return mapOf(
"pointer" to pointer,
"width" to width.toLong(),
"height" to height.toLong(),
"rowBytes" to (width * 4).toLong()
)
} catch (e: Exception) {
NativeBuffer.free(pointer)
throw e
} finally {
recycle()
}
}
class LocalImagesImpl(context: Context) : LocalImageApi {
private val ctx: Context = context.applicationContext
private val resolver: ContentResolver = ctx.contentResolver
private val requestThread = Executors.newSingleThreadExecutor()
@@ -38,21 +72,8 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
private val requestMap = ConcurrentHashMap<Long, Request>()
companion object {
val CANCELLED = Result.success<Map<String, Long>>(mapOf())
val CANCELLED = Result.success<Map<String, Long>?>(null)
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) {
@@ -63,7 +84,8 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
val res = mapOf(
"pointer" to image.pointer,
"width" to image.width.toLong(),
"height" to image.height.toLong()
"height" to image.height.toLong(),
"rowBytes" to (image.width * 4).toLong()
)
callback(Result.success(res))
} catch (e: Exception) {
@@ -78,12 +100,17 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
width: Long,
height: Long,
isVideo: Boolean,
callback: (Result<Map<String, Long>>) -> Unit
preferEncoded: Boolean,
callback: (Result<Map<String, Long>?>) -> Unit
) {
val signal = CancellationSignal()
val task = threadPool.submit {
try {
getThumbnailBufferInternal(assetId, width, height, isVideo, callback, signal)
if (preferEncoded) {
getEncodedImageInternal(assetId, callback, signal)
} else {
getThumbnailBufferInternal(assetId, width, height, isVideo, callback, signal)
}
} catch (e: Exception) {
when (e) {
is OperationCanceledException -> callback(CANCELLED)
@@ -98,7 +125,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
requestMap[requestId] = request
}
override fun cancelImageRequest(requestId: Long) {
override fun cancelRequest(requestId: Long) {
val request = requestMap.remove(requestId) ?: return
request.taskFuture.cancel(false)
request.cancellationSignal.cancel()
@@ -112,12 +139,41 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
}
}
private fun getEncodedImageInternal(
assetId: String,
callback: (Result<Map<String, Long>?>) -> Unit,
signal: CancellationSignal
) {
signal.throwIfCanceled()
val id = assetId.toLong()
val uri = ContentUris.withAppendedId(Images.Media.EXTERNAL_CONTENT_URI, id)
signal.throwIfCanceled()
val bytes = resolver.openInputStream(uri)?.use { it.readBytes() }
?: throw IOException("Could not read image data for $assetId")
signal.throwIfCanceled()
val pointer = NativeBuffer.allocate(bytes.size)
try {
val buffer = NativeBuffer.wrap(pointer, bytes.size)
buffer.put(bytes)
signal.throwIfCanceled()
callback(Result.success(mapOf(
"pointer" to pointer,
"length" to bytes.size.toLong()
)))
} catch (e: Exception) {
NativeBuffer.free(pointer)
throw e
}
}
private fun getThumbnailBufferInternal(
assetId: String,
width: Long,
height: Long,
isVideo: Boolean,
callback: (Result<Map<String, Long>>) -> Unit,
callback: (Result<Map<String, Long>?>) -> Unit,
signal: CancellationSignal
) {
signal.throwIfCanceled()
@@ -131,31 +187,12 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
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()
val res = bitmap.toNativeBuffer()
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))
}
}
@@ -191,16 +228,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
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))
}
ImageDecoder.createSource(resolver, uri).decodeBitmap(target)
} else {
val ref =
Glide.with(ctx).asBitmap().priority(Priority.IMMEDIATE).load(uri).disallowHardwareConfig()
@@ -0,0 +1,123 @@
// Autogenerated from Pigeon (v26.0.2), 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 RemoteImagesPigeonUtils {
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 RemoteImagesPigeonCodec : 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 RemoteImageApi {
fun requestImage(url: String, requestId: Long, preferEncoded: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
fun cancelRequest(requestId: Long)
fun clearCache(callback: (Result<Long>) -> Unit)
companion object {
/** The codec used by RemoteImageApi. */
val codec: MessageCodec<Any?> by lazy {
RemoteImagesPigeonCodec()
}
/** Sets up an instance of `RemoteImageApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: RemoteImageApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val urlArg = args[0] as String
val requestIdArg = args[1] as Long
val preferEncodedArg = args[2] as Boolean
api.requestImage(urlArg, requestIdArg, preferEncodedArg) { result: Result<Map<String, Long>?> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(RemoteImagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(RemoteImagesPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.RemoteImageApi.cancelRequest$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.cancelRequest(requestIdArg)
listOf(null)
} catch (exception: Throwable) {
RemoteImagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.RemoteImageApi.clearCache$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.clearCache{ result: Result<Long> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(RemoteImagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(RemoteImagesPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
@@ -0,0 +1,421 @@
package app.alextran.immich.images
import android.content.Context
import android.os.CancellationSignal
import android.os.OperationCanceledException
import app.alextran.immich.INITIAL_BUFFER_SIZE
import app.alextran.immich.NativeBuffer
import app.alextran.immich.NativeByteBuffer
import app.alextran.immich.core.HttpClientManager
import kotlinx.coroutines.*
import okhttp3.Cache
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.chromium.net.CronetException
import org.chromium.net.UrlRequest
import org.chromium.net.UrlResponseInfo
import java.io.EOFException
import java.io.File
import java.io.IOException
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentHashMap
private class RemoteRequest(val cancellationSignal: CancellationSignal)
class RemoteImagesImpl(context: Context) : RemoteImageApi {
private val requestMap = ConcurrentHashMap<Long, RemoteRequest>()
init {
ImageFetcherManager.initialize(context)
}
companion object {
val CANCELLED = Result.success<Map<String, Long>?>(null)
}
override fun requestImage(
url: String,
requestId: Long,
@Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
callback: (Result<Map<String, Long>?>) -> Unit
) {
val signal = CancellationSignal()
requestMap[requestId] = RemoteRequest(signal)
ImageFetcherManager.fetch(
url,
signal,
onSuccess = { buffer ->
requestMap.remove(requestId)
if (signal.isCanceled) {
NativeBuffer.free(buffer.pointer)
return@fetch callback(CANCELLED)
}
callback(
Result.success(
mapOf(
"pointer" to buffer.pointer,
"length" to buffer.offset.toLong()
)
)
)
},
onFailure = { e ->
requestMap.remove(requestId)
val result = if (signal.isCanceled) CANCELLED else Result.failure(e)
callback(result)
}
)
}
override fun cancelRequest(requestId: Long) {
requestMap.remove(requestId)?.cancellationSignal?.cancel()
}
override fun clearCache(callback: (Result<Long>) -> Unit) {
CoroutineScope(Dispatchers.IO).launch {
try {
ImageFetcherManager.clearCache(callback)
} catch (e: Exception) {
callback(Result.failure(e))
}
}
}
}
private object ImageFetcherManager {
private lateinit var cacheDir: File
private lateinit var fetcher: ImageFetcher
private var initialized = false
fun initialize(context: Context) {
if (initialized) return
synchronized(this) {
if (initialized) return
cacheDir = context.cacheDir
fetcher = build()
HttpClientManager.addClientChangedListener(::invalidate)
initialized = true
}
}
fun fetch(
url: String,
signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit,
) {
fetcher.fetch(url, signal, onSuccess, onFailure)
}
fun clearCache(onCleared: (Result<Long>) -> Unit) {
fetcher.clearCache(onCleared)
}
private fun invalidate() {
synchronized(this) {
val oldFetcher = fetcher
fetcher = build()
oldFetcher.drain()
}
}
private fun build(): ImageFetcher {
return if (HttpClientManager.isMtls) {
OkHttpImageFetcher.create(cacheDir)
} else {
CronetImageFetcher()
}
}
}
private sealed interface ImageFetcher {
fun fetch(
url: String,
signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit,
)
fun drain()
fun clearCache(onCleared: (Result<Long>) -> Unit)
}
private class CronetImageFetcher : ImageFetcher {
private val stateLock = Any()
private var activeCount = 0
private var draining = false
private var onCacheCleared: ((Result<Long>) -> Unit)? = null
override fun fetch(
url: String,
signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit,
) {
synchronized(stateLock) {
if (draining) {
onFailure(IllegalStateException("Engine is draining"))
return
}
activeCount++
}
val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
val requestBuilder = HttpClientManager.cronetEngine!!
.newUrlRequestBuilder(url, callback, HttpClientManager.cronetExecutor)
HttpClientManager.getAuthHeaders(url).forEach { (key, value) ->
requestBuilder.addHeader(key, value)
}
val request = requestBuilder.build()
signal.setOnCancelListener(request::cancel)
request.start()
}
private fun onComplete() {
val didDrain = synchronized(stateLock) {
activeCount--
draining && activeCount == 0
}
if (didDrain) {
onDrained()
}
}
override fun drain() {
val didDrain = synchronized(stateLock) {
if (draining) return
draining = true
activeCount == 0
}
if (didDrain) {
onDrained()
}
}
private fun onDrained() {
val onCacheCleared = synchronized(stateLock) {
val onCacheCleared = this.onCacheCleared
this.onCacheCleared = null
onCacheCleared
} ?: return
CoroutineScope(Dispatchers.IO).launch {
val result = HttpClientManager.rebuildCronetEngine()
synchronized(stateLock) { draining = false }
onCacheCleared(result)
}
}
override fun clearCache(onCleared: (Result<Long>) -> Unit) {
synchronized(stateLock) {
if (onCacheCleared != null) {
return onCleared(Result.success(-1))
}
onCacheCleared = onCleared
}
drain()
}
private class FetchCallback(
private val onSuccess: (NativeByteBuffer) -> Unit,
private val onFailure: (Exception) -> Unit,
private val onComplete: () -> Unit,
) : UrlRequest.Callback() {
private var buffer: NativeByteBuffer? = null
private var wrapped: ByteBuffer? = null
private var error: Exception? = null
override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newUrl: String) {
request.followRedirect()
}
override fun onResponseStarted(request: UrlRequest, info: UrlResponseInfo) {
if (info.httpStatusCode !in 200..299) {
error = IOException("HTTP ${info.httpStatusCode}: ${info.httpStatusText}")
return request.cancel()
}
try {
val contentLength = info.allHeaders["content-length"]?.firstOrNull()?.toIntOrNull() ?: 0
if (contentLength > 0) {
buffer = NativeByteBuffer(contentLength + 1)
wrapped = NativeBuffer.wrap(buffer!!.pointer, contentLength + 1)
request.read(wrapped)
} else {
buffer = NativeByteBuffer(INITIAL_BUFFER_SIZE)
request.read(buffer!!.wrapRemaining())
}
} catch (e: Exception) {
error = e
return request.cancel()
}
}
override fun onReadCompleted(
request: UrlRequest,
info: UrlResponseInfo,
byteBuffer: ByteBuffer
) {
try {
val buf = if (wrapped == null) {
buffer!!.run {
advance(byteBuffer.position())
ensureHeadroom()
wrapRemaining()
}
} else {
wrapped
}
request.read(buf)
} catch (e: Exception) {
error = e
return request.cancel()
}
}
override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) {
wrapped?.let { buffer!!.advance(it.position()) }
onSuccess(buffer!!)
onComplete()
}
override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: CronetException) {
buffer?.free()
onFailure(error)
onComplete()
}
override fun onCanceled(request: UrlRequest, info: UrlResponseInfo?) {
buffer?.free()
onFailure(error ?: OperationCanceledException())
onComplete()
}
}
}
private class OkHttpImageFetcher private constructor(
private val client: OkHttpClient,
) : ImageFetcher {
private val stateLock = Any()
private var activeCount = 0
private var draining = false
companion object {
fun create(cacheDir: File): OkHttpImageFetcher {
val dir = File(cacheDir, "okhttp")
val client = HttpClientManager.getClient().newBuilder()
.cache(Cache(File(dir, "thumbnails"), HttpClientManager.MEDIA_CACHE_SIZE_BYTES))
.build()
return OkHttpImageFetcher(client)
}
}
private fun onComplete() {
val shouldClose = synchronized(stateLock) {
activeCount--
draining && activeCount == 0
}
if (shouldClose) {
client.cache?.close()
}
}
override fun fetch(
url: String,
signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit,
) {
synchronized(stateLock) {
if (draining) {
return onFailure(IllegalStateException("Client is draining"))
}
activeCount++
}
val requestBuilder = Request.Builder().url(url)
val call = client.newCall(requestBuilder.build())
signal.setOnCancelListener(call::cancel)
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
onFailure(e)
onComplete()
}
override fun onResponse(call: Call, response: Response) {
response.use {
if (!response.isSuccessful) {
return onFailure(IOException("HTTP ${response.code}: ${response.message}")).also { onComplete() }
}
val body = response.body
?: return onFailure(IOException("Empty response body")).also { onComplete() }
if (call.isCanceled()) {
onFailure(OperationCanceledException())
return onComplete()
}
body.source().use { source ->
val length = body.contentLength().toInt()
val buffer = NativeByteBuffer(if (length > 0) length else INITIAL_BUFFER_SIZE)
try {
if (length > 0) {
val wrapped = NativeBuffer.wrap(buffer.pointer, length)
while (wrapped.hasRemaining()) {
if (call.isCanceled()) throw OperationCanceledException()
if (source.read(wrapped) == -1) throw EOFException()
}
buffer.advance(length)
} else {
while (true) {
if (call.isCanceled()) throw OperationCanceledException()
val bytesRead = source.read(buffer.wrapRemaining())
if (bytesRead == -1) break
buffer.advance(bytesRead)
buffer.ensureHeadroom()
}
}
onSuccess(buffer)
} catch (e: Exception) {
buffer.free()
onFailure(e)
}
onComplete()
}
}
}
})
}
override fun drain() {
val shouldClose = synchronized(stateLock) {
if (draining) return
draining = true
activeCount == 0
}
if (shouldClose) {
client.cache?.close()
}
}
override fun clearCache(onCleared: (Result<Long>) -> Unit) {
try {
val size = client.cache!!.size()
client.cache!!.evictAll()
onCleared(Result.success(size))
} catch (e: Exception) {
onCleared(Result.failure(e))
}
}
}
@@ -7,6 +7,8 @@ package app.alextran.immich.images;
import java.nio.ByteBuffer;
import app.alextran.immich.NativeBuffer;
// modified to use native allocations
public final class ThumbHash {
/**
@@ -56,8 +58,8 @@ public final class ThumbHash {
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);
long pointer = NativeBuffer.allocate(size);
ByteBuffer rgba = NativeBuffer.wrap(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];
@@ -78,6 +78,21 @@ class FlutterError (
val details: Any? = null
) : Throwable()
enum class PlatformAssetPlaybackStyle(val raw: Int) {
UNKNOWN(0),
IMAGE(1),
VIDEO(2),
IMAGE_ANIMATED(3),
LIVE_PHOTO(4),
VIDEO_LOOPING(5);
companion object {
fun ofRaw(raw: Int): PlatformAssetPlaybackStyle? {
return values().firstOrNull { it.raw == raw }
}
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class PlatformAsset (
val id: String,
@@ -89,7 +104,11 @@ data class PlatformAsset (
val height: Long? = null,
val durationInSeconds: Long,
val orientation: Long,
val isFavorite: Boolean
val isFavorite: Boolean,
val adjustmentTime: Long? = null,
val latitude: Double? = null,
val longitude: Double? = null,
val playbackStyle: PlatformAssetPlaybackStyle
)
{
companion object {
@@ -104,7 +123,11 @@ data class PlatformAsset (
val durationInSeconds = pigeonVar_list[7] as Long
val orientation = pigeonVar_list[8] as Long
val isFavorite = pigeonVar_list[9] as Boolean
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite)
val adjustmentTime = pigeonVar_list[10] as Long?
val latitude = pigeonVar_list[11] as Double?
val longitude = pigeonVar_list[12] as Double?
val playbackStyle = pigeonVar_list[13] as PlatformAssetPlaybackStyle
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite, adjustmentTime, latitude, longitude, playbackStyle)
}
}
fun toList(): List<Any?> {
@@ -119,6 +142,10 @@ data class PlatformAsset (
durationInSeconds,
orientation,
isFavorite,
adjustmentTime,
latitude,
longitude,
playbackStyle,
)
}
override fun equals(other: Any?): Boolean {
@@ -243,50 +270,102 @@ data class HashResult (
override fun hashCode(): Int = toList().hashCode()
}
/** Generated class from Pigeon that represents data sent in messages. */
data class CloudIdResult (
val assetId: String,
val error: String? = null,
val cloudId: String? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): CloudIdResult {
val assetId = pigeonVar_list[0] as String
val error = pigeonVar_list[1] as String?
val cloudId = pigeonVar_list[2] as String?
return CloudIdResult(assetId, error, cloudId)
}
}
fun toList(): List<Any?> {
return listOf(
assetId,
error,
cloudId,
)
}
override fun equals(other: Any?): Boolean {
if (other !is CloudIdResult) {
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) {
129.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PlatformAsset.fromList(it)
return (readValue(buffer) as Long?)?.let {
PlatformAssetPlaybackStyle.ofRaw(it.toInt())
}
}
130.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PlatformAlbum.fromList(it)
PlatformAsset.fromList(it)
}
}
131.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
SyncDelta.fromList(it)
PlatformAlbum.fromList(it)
}
}
132.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
SyncDelta.fromList(it)
}
}
133.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
HashResult.fromList(it)
}
}
134.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
CloudIdResult.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) {
is PlatformAsset -> {
is PlatformAssetPlaybackStyle -> {
stream.write(129)
writeValue(stream, value.toList())
writeValue(stream, value.raw)
}
is PlatformAlbum -> {
is PlatformAsset -> {
stream.write(130)
writeValue(stream, value.toList())
}
is SyncDelta -> {
is PlatformAlbum -> {
stream.write(131)
writeValue(stream, value.toList())
}
is HashResult -> {
is SyncDelta -> {
stream.write(132)
writeValue(stream, value.toList())
}
is HashResult -> {
stream.write(133)
writeValue(stream, value.toList())
}
is CloudIdResult -> {
stream.write(134)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
@@ -305,6 +384,8 @@ interface NativeSyncApi {
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset>
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
fun cancelHashing()
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
companion object {
/** The codec used by NativeSyncApi. */
@@ -483,6 +564,38 @@ interface NativeSyncApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.getTrashedAssets())
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val assetIdsArg = args[0] as List<String>
val wrapped: List<Any?> = try {
listOf(api.getCloudIdForAssetIds(assetIdsArg))
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
@@ -21,4 +21,9 @@ class NativeSyncApiImpl26(context: Context) : NativeSyncApiImplBase(context), Na
override fun getMediaChanges(): SyncDelta {
throw IllegalStateException("Method not supported on this Android version.")
}
override fun getTrashedAssets(): Map<String, List<PlatformAsset>> {
//Method not supported on this Android version.
return emptyMap()
}
}
@@ -1,7 +1,9 @@
package app.alextran.immich.sync
import android.content.ContentResolver
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.annotation.RequiresExtension
@@ -86,4 +88,29 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
// Unmounted volumes are handled in dart when the album is removed
return SyncDelta(hasChanges, changed, deleted, assetAlbums)
}
override fun getTrashedAssets(): Map<String, List<PlatformAsset>> {
val result = LinkedHashMap<String, MutableList<PlatformAsset>>()
val volumes = MediaStore.getExternalVolumeNames(ctx)
for (volume in volumes) {
val queryArgs = Bundle().apply {
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, MEDIA_SELECTION)
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, MEDIA_SELECTION_ARGS)
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
}
getCursor(volume, queryArgs).use { cursor ->
getAssets(cursor).forEach { res ->
if (res is AssetResult.ValidAsset) {
result.getOrPut(res.albumId) { mutableListOf() }.add(res.asset)
}
}
}
}
return result.mapValues { it.value.toList() }
}
}
@@ -4,15 +4,25 @@ import android.annotation.SuppressLint
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import androidx.exifinterface.media.ExifInterface
import android.os.Build
import android.os.Bundle
import android.os.ext.SdkExtensions
import android.provider.MediaStore
import android.util.Base64
import android.util.Log
import androidx.core.database.getStringOrNull
import app.alextran.immich.core.ImmichPlugin
import com.bumptech.glide.Glide
import com.bumptech.glide.load.ImageHeaderParser
import com.bumptech.glide.load.ImageHeaderParserUtils
import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
@@ -20,13 +30,14 @@ import kotlinx.coroutines.sync.withPermit
import java.io.File
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()
data class InvalidAsset(val assetId: String) : AssetResult()
}
private const val TAG = "NativeSyncApiImplBase"
@SuppressLint("InlinedApi")
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
private val ctx: Context = context.applicationContext
@@ -38,6 +49,13 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
private val hashSemaphore = Semaphore(MAX_CONCURRENT_HASH_OPERATIONS)
private const val HASHING_CANCELLED_CODE = "HASH_CANCELLED"
// MediaStore.Files.FileColumns.SPECIAL_FORMAT — S Extensions 21+
// https://developer.android.com/reference/android/provider/MediaStore.Files.FileColumns#SPECIAL_FORMAT
private const val SPECIAL_FORMAT_COLUMN = "_special_format"
private const val SPECIAL_FORMAT_GIF = 1
private const val SPECIAL_FORMAT_MOTION_PHOTO = 2
private const val SPECIAL_FORMAT_ANIMATED_WEBP = 3
const val MEDIA_SELECTION =
"(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)"
val MEDIA_SELECTION_ARGS = arrayOf(
@@ -59,12 +77,28 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
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) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
add(MediaStore.MediaColumns.IS_FAVORITE)
}
if (hasSpecialFormatColumn()) {
add(SPECIAL_FORMAT_COLUMN)
} else {
// fallback to mimetype and xmp for playback style detection on older Android versions
// both only needed if special format column is not available
add(MediaStore.MediaColumns.MIME_TYPE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
add(MediaStore.MediaColumns.XMP)
}
}
}.toTypedArray()
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
// _special_format requires S Extensions 21+
// https://developer.android.com/reference/android/provider/MediaStore.Files.FileColumns#SPECIAL_FORMAT
private fun hasSpecialFormatColumn(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 21
}
protected fun getCursor(
@@ -81,6 +115,16 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
sortOrder,
)
protected fun getCursor(
volume: String,
queryArgs: Bundle
): Cursor? = ctx.contentResolver.query(
MediaStore.Files.getContentUri(volume),
ASSET_PROJECTION,
queryArgs,
null
)
protected fun getAssets(cursor: Cursor?): Sequence<AssetResult> {
return sequence {
cursor?.use { c ->
@@ -91,6 +135,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
val dateAddedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED)
val dateModifiedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
val mediaTypeColumn = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE)
val mimeTypeColumn = c.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE)
val bucketIdColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID)
val widthColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
val heightColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
@@ -98,9 +143,12 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
val orientationColumn =
c.getColumnIndexOrThrow(MediaStore.MediaColumns.ORIENTATION)
val favoriteColumn = c.getColumnIndex(MediaStore.MediaColumns.IS_FAVORITE)
val specialFormatColumn = c.getColumnIndex(SPECIAL_FORMAT_COLUMN)
val xmpColumn = c.getColumnIndex(MediaStore.MediaColumns.XMP)
while (c.moveToNext()) {
val id = c.getLong(idColumn).toString()
val numericId = c.getLong(idColumn)
val id = numericId.toString()
val name = c.getStringOrNull(nameColumn)
val bucketId = c.getStringOrNull(bucketIdColumn)
val path = c.getStringOrNull(dataColumn)
@@ -114,10 +162,11 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
continue
}
val mediaType = when (c.getInt(mediaTypeColumn)) {
MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE -> 1
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> 2
else -> 0
val rawMediaType = c.getInt(mediaTypeColumn)
val assetType: Long = when (rawMediaType) {
MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE -> 1L
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> 2L
else -> 0L
}
// Date taken is milliseconds since epoch, Date added is seconds since epoch
val createdAt = (c.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000))
@@ -127,22 +176,28 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
val width = c.getInt(widthColumn).toLong()
val height = c.getInt(heightColumn).toLong()
// Duration is milliseconds
val duration = if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) 0
val duration = if (rawMediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) 0L
else c.getLong(durationColumn) / 1000
val orientation = c.getInt(orientationColumn)
val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0
val playbackStyle = detectPlaybackStyle(
numericId, rawMediaType, mimeTypeColumn, specialFormatColumn, xmpColumn, c
)
val isFlipped = orientation == 90 || orientation == 270
val asset = PlatformAsset(
id,
name,
mediaType.toLong(),
assetType,
createdAt,
modifiedAt,
width,
height,
if (isFlipped) height else width,
if (isFlipped) width else height,
duration,
orientation.toLong(),
0L,
isFavorite,
playbackStyle = playbackStyle,
)
yield(AssetResult.ValidAsset(asset, bucketId))
}
@@ -150,6 +205,92 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
}
}
/**
* Detects the playback style for an asset using _special_format (SDK Extension 21+)
* or XMP / MIME / RIFF header fallbacks.
*/
@SuppressLint("NewApi")
private fun detectPlaybackStyle(
assetId: Long,
rawMediaType: Int,
mimeTypeColumn: Int,
specialFormatColumn: Int,
xmpColumn: Int,
cursor: Cursor
): PlatformAssetPlaybackStyle {
// video currently has no special formats, so we can short circuit and avoid unnecessary work
if (rawMediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO) {
return PlatformAssetPlaybackStyle.VIDEO
}
// API 33+: use _special_format from cursor
if (specialFormatColumn != -1) {
val specialFormat = cursor.getInt(specialFormatColumn)
return when {
specialFormat == SPECIAL_FORMAT_MOTION_PHOTO -> PlatformAssetPlaybackStyle.LIVE_PHOTO
specialFormat == SPECIAL_FORMAT_GIF || specialFormat == SPECIAL_FORMAT_ANIMATED_WEBP -> PlatformAssetPlaybackStyle.IMAGE_ANIMATED
rawMediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE -> PlatformAssetPlaybackStyle.IMAGE
else -> PlatformAssetPlaybackStyle.UNKNOWN
}
}
if (rawMediaType != MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) {
return PlatformAssetPlaybackStyle.UNKNOWN
}
val mimeType = if (mimeTypeColumn != -1) cursor.getString(mimeTypeColumn) else null
// GIFs are always animated and cannot be motion photos; no I/O needed
if (mimeType == "image/gif") {
return PlatformAssetPlaybackStyle.IMAGE_ANIMATED
}
val uri = ContentUris.withAppendedId(
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
assetId
)
// Only WebP needs a stream check to distinguish static vs animated;
// WebP files are not used as motion photos, so skip XMP detection
if (mimeType == "image/webp") {
try {
val glide = Glide.get(ctx)
ctx.contentResolver.openInputStream(uri)?.use { stream ->
val type = ImageHeaderParserUtils.getType(
listOf(DefaultImageHeaderParser()),
stream,
glide.arrayPool
)
// Also check for GIF just in case MIME type is incorrect; Doesn't hurt performance
if (type == ImageHeaderParser.ImageType.ANIMATED_WEBP || type == ImageHeaderParser.ImageType.GIF) {
return PlatformAssetPlaybackStyle.IMAGE_ANIMATED
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to parse image header for asset $assetId", e)
}
// if mimeType is webp but not animated, its just an image.
return PlatformAssetPlaybackStyle.IMAGE
}
// Read XMP from cursor (API 30+)
val xmp: String? = if (xmpColumn != -1) {
cursor.getBlob(xmpColumn)?.toString(Charsets.UTF_8)
} else {
// if xmp column is not available, we are on API 29 or below
// theoretically there were motion photos but the Camera:MotionPhoto xmp tag
// was only added in Android 11, so we should not have to worry about parsing XMP on older versions
null
}
if (xmp != null && "Camera:MotionPhoto" in xmp) {
return PlatformAssetPlaybackStyle.LIVE_PHOTO
}
return PlatformAssetPlaybackStyle.IMAGE
}
fun getAlbums(): List<PlatformAlbum> {
val albums = mutableListOf<PlatformAlbum>()
val albumsCount = mutableMapOf<String, Int>()
@@ -287,7 +428,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
var bytesRead: Int
val buffer = ByteArray(HASH_BUFFER_SIZE)
while (inputStream.read(buffer).also { bytesRead = it } > 0) {
coroutineContext.ensureActive()
currentCoroutineContext().ensureActive()
digest.update(buffer, 0, bytesRead)
}
} ?: return HashResult(assetId, "Cannot open input stream for asset", null)
@@ -305,4 +446,10 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
hashTask?.cancel()
hashTask = null
}
// This method is only implemented on iOS; on Android, we do not have a concept of cloud IDs
@Suppress("unused", "UNUSED_PARAMETER")
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
return emptyList()
}
}
@@ -101,7 +101,7 @@ class ImmichAPI(cfg: ServerConfig) {
}
suspend fun fetchImage(asset: Asset): Bitmap = withContext(Dispatchers.IO) {
val url = buildRequestURL("/assets/${asset.id}/thumbnail", listOf("size" to "preview"))
val url = buildRequestURL("/assets/${asset.id}/thumbnail", listOf("size" to "preview", "edited" to "true"))
val connection = url.openConnection()
val data = connection.getInputStream().readBytes()
BitmapFactory.decodeByteArray(data, 0, data.size)
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3026,
"android.injected.version.name" => "2.2.3",
"android.injected.version.code" => 3041,
"android.injected.version.name" => "2.6.3",
}
)
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')
+331 -30
View File
@@ -3,7 +3,99 @@
import 'dart:convert';
import 'dart:io';
const _kReservedWords = ['continue'];
const _kReservedWords = [
'abstract',
'as',
'assert',
'async',
'await',
'break',
'case',
'catch',
'class',
'const',
'continue',
'covariant',
'default',
'deferred',
'do',
'dynamic',
'else',
'enum',
'export',
'extends',
'extension',
'external',
'factory',
'false',
'final',
'finally',
'for',
'Function',
'get',
'hide',
'if',
'implements',
'import',
'in',
'interface',
'is',
'late',
'library',
'mixin',
'new',
'null',
'on',
'operator',
'part',
'required',
'rethrow',
'return',
'sealed',
'set',
'show',
'static',
'super',
'switch',
'sync',
'this',
'throw',
'true',
'try',
'typedef',
'var',
'void',
'when',
'while',
'with',
'yield',
];
const _kIntParamNames = [
'count',
'number',
'amount',
'total',
'index',
'size',
'length',
'width',
'height',
'year',
'month',
'day',
'hour',
'minute',
'second',
'page',
'limit',
'offset',
'max',
'min',
'id',
'num',
'quantity',
];
void main() async {
final sourceFile = File('../i18n/en.json');
@@ -15,49 +107,258 @@ void main() async {
final outputDir = Directory('lib/generated');
await outputDir.create(recursive: true);
final outputFile = File('lib/generated/intl_keys.g.dart');
await _generate(sourceFile, outputFile);
final content = await sourceFile.readAsString();
final translations = json.decode(content) as Map<String, dynamic>;
final outputFile = File('lib/generated/translations.g.dart');
await _generateTranslations(translations, outputFile);
print('Generated ${outputFile.path}');
}
Future<void> _generate(File source, File output) async {
final content = await source.readAsString();
final translations = json.decode(content) as Map<String, dynamic>;
class TranslationNode {
final String key;
final String? value;
final Map<String, TranslationNode> children;
final List<TranslationParam> params;
const TranslationNode({
required this.key,
this.value,
Map<String, TranslationNode>? children,
List<TranslationParam>? params,
}) : children = children ?? const {},
params = params ?? const [];
bool get isLeaf => value != null;
bool get hasParams => params.isNotEmpty;
}
class TranslationParam {
final String name;
final String type;
const TranslationParam(this.name, this.type);
}
Future<void> _generateTranslations(Map<String, dynamic> translations, File output) async {
final root = _buildTranslationTree('', translations);
final buffer = StringBuffer('''
// DO NOT EDIT. This is code generated via generate_keys.dart
abstract class IntlKeys {
''');
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/widgets.dart';
import 'package:intl/message_format.dart';
_writeKeys(buffer, translations);
buffer.writeln('}');
await output.writeAsString(buffer.toString());
extension TranslationsExtension on BuildContext {
Translations get t => Translations.of(this);
}
void _writeKeys(
StringBuffer buffer,
Map<String, dynamic> map, [
String prefix = '',
]) {
for (final entry in map.entries) {
final key = entry.key;
final value = entry.value;
class StaticTranslations {
StaticTranslations._();
static final instance = Translations._(null);
}
if (value is Map<String, dynamic>) {
_writeKeys(buffer, value, prefix.isEmpty ? key : '${prefix}_$key');
} else {
final name = _cleanName(prefix.isEmpty ? key : '${prefix}_$key');
final path = prefix.isEmpty ? key : '$prefix.$key'.replaceAll('_', '.');
buffer.writeln(' static const $name = \'$path\';');
abstract class _BaseTranslations {
BuildContext? get _context;
String _t(String key, [Map<String, Object>? args]) {
if (key.isEmpty) return '';
try {
final translated = key.tr(context: _context);
return args != null
? MessageFormat(translated, locale: Intl.defaultLocale ?? 'en').format(args)
: translated;
} catch (e) {
return key;
}
}
}
String _cleanName(String name) {
name = name.replaceAll(RegExp(r'[^a-zA-Z0-9_]'), '_');
if (RegExp(r'^[0-9]').hasMatch(name)) name = 'k_$name';
if (_kReservedWords.contains(name)) name = '${name}_';
class Translations extends _BaseTranslations {
@override
final BuildContext? _context;
Translations._(this._context);
static Translations of(BuildContext context) {
context.locale;
return Translations._(context);
}
''');
_generateClassMembers(buffer, root, ' ');
buffer.writeln('}');
_generateNestedClasses(buffer, root);
await output.writeAsString(buffer.toString());
}
TranslationNode _buildTranslationTree(String key, dynamic value) {
if (value is Map<String, dynamic>) {
final children = <String, TranslationNode>{};
for (final entry in value.entries) {
children[entry.key] = _buildTranslationTree(entry.key, entry.value);
}
return TranslationNode(key: key, children: children);
} else {
final stringValue = value.toString();
final params = _extractParams(stringValue);
return TranslationNode(key: key, value: stringValue, params: params);
}
}
List<TranslationParam> _extractParams(String value) {
final params = <String, TranslationParam>{};
final icuRegex = RegExp(r'\{(\w+),\s*(plural|select|number|date|time)([^}]*(?:\{[^}]*\}[^}]*)*)\}');
for (final match in icuRegex.allMatches(value)) {
final name = match.group(1)!;
final icuType = match.group(2)!;
final icuContent = match.group(3) ?? '';
if (params.containsKey(name)) continue;
String type;
if (icuType == 'plural' || icuType == 'number') {
type = 'int';
} else if (icuType == 'select') {
final hasTrueFalse = RegExp(r',\s*(true|false)\s*\{').hasMatch(icuContent);
type = hasTrueFalse ? 'bool' : 'String';
} else {
type = 'String';
}
params[name] = TranslationParam(name, type);
}
var cleanedValue = value;
var depth = 0;
var icuStart = -1;
for (var i = 0; i < value.length; i++) {
if (value[i] == '{') {
if (depth == 0) icuStart = i;
depth++;
} else if (value[i] == '}') {
depth--;
if (depth == 0 && icuStart >= 0) {
final block = value.substring(icuStart, i + 1);
if (RegExp(r'^\{\w+,').hasMatch(block)) {
cleanedValue = cleanedValue.replaceFirst(block, '');
}
icuStart = -1;
}
}
}
final simpleRegex = RegExp(r'\{(\w+)\}');
for (final match in simpleRegex.allMatches(cleanedValue)) {
final name = match.group(1)!;
if (params.containsKey(name)) continue;
String type;
if (_kIntParamNames.contains(name.toLowerCase())) {
type = 'int';
} else {
type = 'Object';
}
params[name] = TranslationParam(name, type);
}
return params.values.toList();
}
void _generateClassMembers(StringBuffer buffer, TranslationNode node, String indent, [String keyPrefix = '']) {
final sortedKeys = node.children.keys.toList()..sort();
for (final childKey in sortedKeys) {
final child = node.children[childKey]!;
final dartName = _escapeName(childKey);
final fullKey = keyPrefix.isEmpty ? childKey : '$keyPrefix.$childKey';
if (child.isLeaf) {
if (child.hasParams) {
_generateMethod(buffer, dartName, fullKey, child.params, indent);
} else {
_generateGetter(buffer, dartName, fullKey, indent);
}
} else {
final className = _toNestedClassName(keyPrefix, childKey);
buffer.writeln('${indent}late final $dartName = $className._(_context);');
}
}
}
void _generateGetter(StringBuffer buffer, String dartName, String translationKey, String indent) {
buffer.writeln('${indent}String get $dartName => _t(\'$translationKey\');');
}
void _generateMethod(
StringBuffer buffer,
String dartName,
String translationKey,
List<TranslationParam> params,
String indent,
) {
final paramList = params.map((p) => 'required ${p.type} ${_escapeName(p.name)}').join(', ');
final argsMap = params.map((p) => '\'${p.name}\': ${_escapeName(p.name)}').join(', ');
buffer.writeln('${indent}String $dartName({$paramList}) => _t(\'$translationKey\', {$argsMap});');
}
void _generateNestedClasses(StringBuffer buffer, TranslationNode node, [String keyPrefix = '']) {
final sortedKeys = node.children.keys.toList()..sort();
for (final childKey in sortedKeys) {
final child = node.children[childKey]!;
final fullKey = keyPrefix.isEmpty ? childKey : '$keyPrefix.$childKey';
if (!child.isLeaf && child.children.isNotEmpty) {
final className = _toNestedClassName(keyPrefix, childKey);
buffer.writeln();
buffer.writeln('class $className extends _BaseTranslations {');
buffer.writeln(' @override');
buffer.writeln(' final BuildContext? _context;');
buffer.writeln(' $className._(this._context);');
_generateClassMembers(buffer, child, ' ', fullKey);
buffer.writeln('}');
_generateNestedClasses(buffer, child, fullKey);
}
}
}
String _toNestedClassName(String prefix, String key) {
final parts = <String>[];
if (prefix.isNotEmpty) {
parts.addAll(prefix.split('.'));
}
parts.add(key);
final result = StringBuffer('_');
for (final part in parts) {
final words = part.split('_');
for (final word in words) {
if (word.isNotEmpty) {
result.write(word[0].toUpperCase());
if (word.length > 1) {
result.write(word.substring(1).toLowerCase());
}
}
}
}
result.write('Translations');
return result.toString();
}
String _escapeName(String name) {
if (_kReservedWords.contains(name)) {
return '$name\$';
}
if (RegExp(r'^[0-9]').hasMatch(name)) {
return 'k$name';
}
return name;
}
+1 -1
View File
@@ -1 +1 @@
version: '>=1.29.0 <=1.30.0'
version: '>=1.29.0 <=1.36.0'
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -1
View File
@@ -33,4 +33,5 @@ Runner/GeneratedPluginRegistrant.*
!default.perspectivev3
fastlane/report.xml
Gemfile.lock
Gemfile.lock
certs/
+2
View File
@@ -121,4 +121,6 @@ post_install do |installer|
end
# End of the permission_handler configuration
end
system("defaults write com.apple.dt.Xcode IDESkipPackagePluginFingerprintValidatation -bool YES")
system("defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES")
end
+14 -51
View File
@@ -6,41 +6,10 @@ PODS:
- FlutterMacOS
- connectivity_plus (0.0.1):
- Flutter
- device_info_plus (0.0.1):
- cupertino_http (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.9):
- DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource
- DKImagePickerController/ImageDataManager (4.3.9)
- DKImagePickerController/PhotoGallery (4.3.9):
- DKImagePickerController/Core
- DKPhotoGallery
- DKImagePickerController/Resource (4.3.9)
- DKPhotoGallery (0.0.19):
- DKPhotoGallery/Core (= 0.0.19)
- DKPhotoGallery/Model (= 0.0.19)
- DKPhotoGallery/Preview (= 0.0.19)
- DKPhotoGallery/Resource (= 0.0.19)
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Core (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Preview
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Model (0.0.19):
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Preview (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Resource
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Resource (0.0.19):
- SDWebImage
- SwiftyGif
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- FlutterMacOS
- device_info_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
- flutter_local_notifications (0.0.1):
@@ -77,6 +46,8 @@ PODS:
- Flutter
- network_info_plus (0.0.1):
- Flutter
- objective_c (0.0.1):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
@@ -88,9 +59,6 @@ PODS:
- Flutter
- FlutterMacOS
- SAMKeychain (1.5.3)
- SDWebImage (5.21.0):
- SDWebImage/Core (= 5.21.0)
- SDWebImage/Core (5.21.0)
- share_handler_ios (0.0.14):
- Flutter
- share_handler_ios/share_handler_ios_models (= 0.0.14)
@@ -126,7 +94,6 @@ PODS:
- sqlite3/fts5
- sqlite3/perf-threadsafe
- sqlite3/rtree
- SwiftyGif (5.4.5)
- url_launcher_ios (0.0.1):
- Flutter
- wakelock_plus (0.0.1):
@@ -136,8 +103,8 @@ DEPENDENCIES:
- background_downloader (from `.symlinks/plugins/background_downloader/ios`)
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- cupertino_http (from `.symlinks/plugins/cupertino_http/darwin`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- Flutter (from `Flutter`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
@@ -154,6 +121,7 @@ DEPENDENCIES:
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
- native_video_player (from `.symlinks/plugins/native_video_player/ios`)
- network_info_plus (from `.symlinks/plugins/network_info_plus/ios`)
- objective_c (from `.symlinks/plugins/objective_c/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
@@ -169,13 +137,9 @@ DEPENDENCIES:
SPEC REPOS:
trunk:
- DKImagePickerController
- DKPhotoGallery
- MapLibre
- SAMKeychain
- SDWebImage
- sqlite3
- SwiftyGif
EXTERNAL SOURCES:
background_downloader:
@@ -184,10 +148,10 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/bonsoir_darwin/darwin"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
cupertino_http:
:path: ".symlinks/plugins/cupertino_http/darwin"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
Flutter:
:path: Flutter
flutter_local_notifications:
@@ -220,6 +184,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/native_video_player/ios"
network_info_plus:
:path: ".symlinks/plugins/network_info_plus/ios"
objective_c:
:path: ".symlinks/plugins/objective_c/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
@@ -249,10 +215,8 @@ SPEC CHECKSUMS:
background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad
bonsoir_darwin: 29c7ccf356646118844721f36e1de4b61f6cbd0e
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
@@ -270,12 +234,12 @@ SPEC CHECKSUMS:
maplibre_gl: 3c924e44725147b03dda33430ad216005b40555f
native_video_player: b65c58951ede2f93d103a25366bdebca95081265
network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc
objective_c: 89e720c30d716b036faf9c9684022048eee1eee2
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
photo_manager: 1d80ae07a89a67dfbcae95953a1e5a24af7c3e62
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868
share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb
share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
@@ -283,10 +247,9 @@ SPEC CHECKSUMS:
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983
sqlite3_flutter_libs: f8fc13346870e73fe35ebf6dbb997fbcd156b241
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
PODFILE CHECKSUM: 7ce312f2beab01395db96f6969d90a447279cf45
PODFILE CHECKSUM: 938abbae4114b9c2140c550a2a0d8f7c674f5dfe
COCOAPODS: 1.16.2
+86 -18
View File
@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
@@ -29,9 +29,15 @@
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 */; };
FE5499F32F1197D8006016CB /* LocalImages.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F12F1197D8006016CB /* LocalImages.g.swift */; };
FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F22F1197D8006016CB /* RemoteImages.g.swift */; };
FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F52F11980E006016CB /* LocalImagesImpl.swift */; };
FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */; };
FE5FE4AE2F30FBC000A71243 /* ImageProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */; };
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 */; };
FEE084F82EC172460045228E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084F72EC172460045228E /* SQLiteData */; };
FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */; };
FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FC2EC1725A0045228E /* StructuredFieldValues */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -115,9 +121,12 @@
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>"; };
FE5499F12F1197D8006016CB /* LocalImages.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalImages.g.swift; sourceTree = "<group>"; };
FE5499F22F1197D8006016CB /* RemoteImages.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImages.g.swift; sourceTree = "<group>"; };
FE5499F52F11980E006016CB /* LocalImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalImagesImpl.swift; sourceTree = "<group>"; };
FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImagesImpl.swift; sourceTree = "<group>"; };
FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessing.swift; 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 */
@@ -153,6 +162,13 @@
path = WidgetExtension;
sourceTree = "<group>";
};
FEE084F22EC172080045228E /* Schemas */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Schemas;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -160,6 +176,9 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
FEE084F82EC172460045228E /* SQLiteData in Frameworks */,
FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */,
FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */,
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -254,6 +273,7 @@
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
FEE084F22EC172080045228E /* Schemas */,
B231F52D2E93A44A00BC45D1 /* Core */,
B25D37792E72CA15008B6CA7 /* Connectivity */,
B21E34A62E5AF9760031FDB9 /* Background */,
@@ -307,9 +327,12 @@
FED3B1952E253E9B0030FD97 /* Images */ = {
isa = PBXGroup;
children = (
FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */,
FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */,
FE5499F52F11980E006016CB /* LocalImagesImpl.swift */,
FE5499F12F1197D8006016CB /* LocalImages.g.swift */,
FE5499F22F1197D8006016CB /* RemoteImages.g.swift */,
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */,
FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */,
FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */,
);
path = Images;
sourceTree = "<group>";
@@ -341,6 +364,7 @@
fileSystemSynchronizedGroups = (
B231F52D2E93A44A00BC45D1 /* Core */,
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
FEE084F22EC172080045228E /* Schemas */,
);
name = Runner;
productName = Runner;
@@ -419,6 +443,10 @@
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */,
FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
@@ -581,12 +609,15 @@
65F32F31299BD2F800CE9261 /* BackgroundServicePlugin.swift in Sources */,
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */,
FE5499F32F1197D8006016CB /* LocalImages.g.swift in Sources */,
FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */,
FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */,
FE5FE4AE2F30FBC000A71243 /* ImageProcessing.swift in Sources */,
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */,
FE5499F82F1198E2006016CB /* RemoteImagesImpl.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 */,
@@ -714,7 +745,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 233;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -858,7 +889,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 233;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -888,7 +919,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 233;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -922,7 +953,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 233;
CURRENT_PROJECT_VERSION = 240;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -965,7 +996,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 233;
CURRENT_PROJECT_VERSION = 240;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1005,7 +1036,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 233;
CURRENT_PROJECT_VERSION = 240;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1044,7 +1075,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 233;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1088,7 +1119,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 233;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1129,7 +1160,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 233;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1201,6 +1232,43 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/pointfreeco/sqlite-data";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.3.0;
};
};
FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/apple/swift-http-structured-headers.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.5.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
FEE084F72EC172460045228E /* SQLiteData */ = {
isa = XCSwiftPackageProductDependency;
package = FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */;
productName = SQLiteData;
};
FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */ = {
isa = XCSwiftPackageProductDependency;
package = FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */;
productName = RawStructuredFieldValues;
};
FEE084FC2EC1725A0045228E /* StructuredFieldValues */ = {
isa = XCSwiftPackageProductDependency;
package = FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */;
productName = StructuredFieldValues;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
@@ -0,0 +1,177 @@
{
"originHash" : "9be33bfaa68721646604aefff3cabbdaf9a193da192aae024c265065671f6c49",
"pins" : [
{
"identity" : "combine-schedulers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/combine-schedulers",
"state" : {
"revision" : "fd16d76fd8b9a976d88bfb6cacc05ca8d19c91b6",
"version" : "1.1.0"
}
},
{
"identity" : "grdb.swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/groue/GRDB.swift",
"state" : {
"revision" : "18497b68fdbb3a09528d260a0a0e1e7e61c8c53d",
"version" : "7.8.0"
}
},
{
"identity" : "opencombine",
"kind" : "remoteSourceControl",
"location" : "https://github.com/OpenCombine/OpenCombine.git",
"state" : {
"revision" : "8576f0d579b27020beccbccc3ea6844f3ddfc2c2",
"version" : "0.14.0"
}
},
{
"identity" : "sqlite-data",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/sqlite-data",
"state" : {
"revision" : "b66b894b9a5710f1072c8eb6448a7edfc2d743d9",
"version" : "1.3.0"
}
},
{
"identity" : "swift-case-paths",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-case-paths",
"state" : {
"revision" : "6989976265be3f8d2b5802c722f9ba168e227c71",
"version" : "1.7.2"
}
},
{
"identity" : "swift-clocks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-clocks",
"state" : {
"revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e",
"version" : "1.0.6"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections",
"state" : {
"revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e",
"version" : "1.3.0"
}
},
{
"identity" : "swift-concurrency-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
"state" : {
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
"version" : "1.3.2"
}
},
{
"identity" : "swift-custom-dump",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-custom-dump",
"state" : {
"revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1",
"version" : "1.3.3"
}
},
{
"identity" : "swift-dependencies",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-dependencies",
"state" : {
"revision" : "a10f9feeb214bc72b5337b6ef6d5a029360db4cc",
"version" : "1.10.0"
}
},
{
"identity" : "swift-http-structured-headers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-structured-headers.git",
"state" : {
"revision" : "a9f3c352f4d46afd155e00b3c6e85decae6bcbeb",
"version" : "1.5.0"
}
},
{
"identity" : "swift-identified-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-identified-collections",
"state" : {
"revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597",
"version" : "1.1.1"
}
},
{
"identity" : "swift-perception",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-perception",
"state" : {
"revision" : "4f47ebafed5f0b0172cf5c661454fa8e28fb2ac4",
"version" : "2.0.9"
}
},
{
"identity" : "swift-sharing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-sharing",
"state" : {
"revision" : "3bfc408cc2d0bee2287c174da6b1c76768377818",
"version" : "2.7.4"
}
},
{
"identity" : "swift-snapshot-testing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-snapshot-testing",
"state" : {
"revision" : "a8b7c5e0ed33d8ab8887d1654d9b59f2cbad529b",
"version" : "1.18.7"
}
},
{
"identity" : "swift-structured-queries",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-structured-queries",
"state" : {
"revision" : "9c84335373bae5f5c9f7b5f0adf3ae10f2cab5b9",
"version" : "0.25.2"
}
},
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-syntax",
"state" : {
"revision" : "4799286537280063c85a32f09884cfbca301b1a1",
"version" : "602.0.0"
}
},
{
"identity" : "swift-tagged",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-tagged",
"state" : {
"revision" : "3907a9438f5b57d317001dc99f3f11b46882272b",
"version" : "0.10.0"
}
},
{
"identity" : "xctest-dynamic-overlay",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
"state" : {
"revision" : "4c27acf5394b645b70d8ba19dc249c0472d5f618",
"version" : "1.7.0"
}
}
],
"version" : 3
}
@@ -0,0 +1,150 @@
{
"originHash" : "9be33bfaa68721646604aefff3cabbdaf9a193da192aae024c265065671f6c49",
"pins" : [
{
"identity" : "combine-schedulers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/combine-schedulers",
"state" : {
"revision" : "5928286acce13def418ec36d05a001a9641086f2",
"version" : "1.0.3"
}
},
{
"identity" : "grdb.swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/groue/GRDB.swift",
"state" : {
"revision" : "aa0079aeb82a4bf00324561a40bffe68c6fe1c26",
"version" : "7.9.0"
}
},
{
"identity" : "sqlite-data",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/sqlite-data",
"state" : {
"revision" : "05704b563ecb7f0bd7e49b6f360a6383a3e53e7d",
"version" : "1.5.1"
}
},
{
"identity" : "swift-clocks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-clocks",
"state" : {
"revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e",
"version" : "1.0.6"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections",
"state" : {
"revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e",
"version" : "1.3.0"
}
},
{
"identity" : "swift-concurrency-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
"state" : {
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
"version" : "1.3.2"
}
},
{
"identity" : "swift-custom-dump",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-custom-dump",
"state" : {
"revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1",
"version" : "1.3.3"
}
},
{
"identity" : "swift-dependencies",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-dependencies",
"state" : {
"revision" : "a10f9feeb214bc72b5337b6ef6d5a029360db4cc",
"version" : "1.10.0"
}
},
{
"identity" : "swift-http-structured-headers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-structured-headers.git",
"state" : {
"revision" : "a9f3c352f4d46afd155e00b3c6e85decae6bcbeb",
"version" : "1.5.0"
}
},
{
"identity" : "swift-identified-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-identified-collections",
"state" : {
"revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597",
"version" : "1.1.1"
}
},
{
"identity" : "swift-perception",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-perception",
"state" : {
"revision" : "4f47ebafed5f0b0172cf5c661454fa8e28fb2ac4",
"version" : "2.0.9"
}
},
{
"identity" : "swift-sharing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-sharing",
"state" : {
"revision" : "3bfc408cc2d0bee2287c174da6b1c76768377818",
"version" : "2.7.4"
}
},
{
"identity" : "swift-snapshot-testing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-snapshot-testing",
"state" : {
"revision" : "a8b7c5e0ed33d8ab8887d1654d9b59f2cbad529b",
"version" : "1.18.7"
}
},
{
"identity" : "swift-structured-queries",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-structured-queries",
"state" : {
"revision" : "d8163b3a98f3c8434c4361e85126db449d84bc66",
"version" : "0.30.0"
}
},
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-syntax",
"state" : {
"revision" : "4799286537280063c85a32f09884cfbca301b1a1",
"version" : "602.0.0"
}
},
{
"identity" : "xctest-dynamic-overlay",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
"state" : {
"revision" : "4c27acf5394b645b70d8ba19dc249c0472d5f618",
"version" : "1.7.0"
}
}
],
"version" : 3
}
+10 -4
View File
@@ -1,5 +1,6 @@
import BackgroundTasks
import Flutter
import native_video_player
import network_info_plus
import path_provider_foundation
import permission_handler_apple
@@ -15,12 +16,14 @@ import UIKit
) -> Bool {
// Required for flutter_local_notification
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
}
SwiftNativeVideoPlayerPlugin.cookieStorage = URLSessionManager.cookieStorage
URLSessionManager.patchBackgroundDownloader()
GeneratedPluginRegistrant.register(with: self)
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
AppDelegate.registerPlugins(with: controller.engine)
AppDelegate.registerPlugins(with: controller.engine, controller: controller)
BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!)
BackgroundServicePlugin.registerBackgroundProcessing()
@@ -51,10 +54,13 @@ import UIKit
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
public static func registerPlugins(with engine: FlutterEngine) {
public static func registerPlugins(with engine: FlutterEngine, controller: FlutterViewController?) {
NativeSyncApiImpl.register(with: engine.registrar(forPlugin: NativeSyncApiImpl.name)!)
ThumbnailApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ThumbnailApiImpl())
LocalImageApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: LocalImageApiImpl())
RemoteImageApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: RemoteImageApiImpl())
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl())
ConnectivityApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ConnectivityApiImpl())
NetworkApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: NetworkApiImpl(viewController: controller))
}
public static func cancelPlugins(with engine: FlutterEngine) {
@@ -95,7 +95,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
// Register plugins in the new engine
GeneratedPluginRegistrant.register(with: engine)
// Register custom plugins
AppDelegate.registerPlugins(with: engine)
AppDelegate.registerPlugins(with: engine, controller: nil)
flutterApi = BackgroundWorkerFlutterApi(binaryMessenger: engine.binaryMessenger)
BackgroundWorkerBgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: self)
@@ -1,6 +1,60 @@
import Network
class ConnectivityApiImpl: ConnectivityApi {
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "ConnectivityMonitor")
private var currentPath: NWPath?
init() {
monitor.pathUpdateHandler = { [weak self] path in
self?.currentPath = path
}
monitor.start(queue: queue)
// Get initial state synchronously
currentPath = monitor.currentPath
}
deinit {
monitor.cancel()
}
func getCapabilities() throws -> [NetworkCapability] {
[]
guard let path = currentPath else {
return []
}
guard path.status == .satisfied else {
return []
}
var capabilities: [NetworkCapability] = []
if path.usesInterfaceType(.wifi) {
capabilities.append(.wifi)
}
if path.usesInterfaceType(.cellular) {
capabilities.append(.cellular)
}
// Check for VPN - iOS reports VPN as .other interface type in many cases
// or through the path's expensive property when on cellular with VPN
if path.usesInterfaceType(.other) {
capabilities.append(.vpn)
}
// Determine if connection is unmetered:
// - Must be on WiFi (not cellular)
// - Must not be expensive (rules out personal hotspot)
// - Must not be constrained (Low Data Mode)
// Note: VPN over cellular should still be considered metered
let isOnCellular = path.usesInterfaceType(.cellular)
let isOnWifi = path.usesInterfaceType(.wifi)
if isOnWifi && !isOnCellular && !path.isExpensive && !path.isConstrained {
capabilities.append(.unmetered)
}
return capabilities
}
}
+330
View File
@@ -0,0 +1,330 @@
// Autogenerated from Pigeon (v26.0.2), 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?
}
func deepEqualsNetwork(_ 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 !deepEqualsNetwork(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 !deepEqualsNetwork(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 deepHashNetwork(value: Any?, hasher: inout Hasher) {
if let valueList = value as? [AnyHashable] {
for item in valueList { deepHashNetwork(value: item, hasher: &hasher) }
return
}
if let valueDict = value as? [AnyHashable: AnyHashable] {
for key in valueDict.keys {
hasher.combine(key)
deepHashNetwork(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 ClientCertData: Hashable {
var data: FlutterStandardTypedData
var password: String
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> ClientCertData? {
let data = pigeonVar_list[0] as! FlutterStandardTypedData
let password = pigeonVar_list[1] as! String
return ClientCertData(
data: data,
password: password
)
}
func toList() -> [Any?] {
return [
data,
password,
]
}
static func == (lhs: ClientCertData, rhs: ClientCertData) -> Bool {
return deepEqualsNetwork(lhs.toList(), rhs.toList()) }
func hash(into hasher: inout Hasher) {
deepHashNetwork(value: toList(), hasher: &hasher)
}
}
/// Generated class from Pigeon that represents data sent in messages.
struct ClientCertPrompt: Hashable {
var title: String
var message: String
var cancel: String
var confirm: String
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> ClientCertPrompt? {
let title = pigeonVar_list[0] as! String
let message = pigeonVar_list[1] as! String
let cancel = pigeonVar_list[2] as! String
let confirm = pigeonVar_list[3] as! String
return ClientCertPrompt(
title: title,
message: message,
cancel: cancel,
confirm: confirm
)
}
func toList() -> [Any?] {
return [
title,
message,
cancel,
confirm,
]
}
static func == (lhs: ClientCertPrompt, rhs: ClientCertPrompt) -> Bool {
return deepEqualsNetwork(lhs.toList(), rhs.toList()) }
func hash(into hasher: inout Hasher) {
deepHashNetwork(value: toList(), hasher: &hasher)
}
}
private class NetworkPigeonCodecReader: FlutterStandardReader {
override func readValue(ofType type: UInt8) -> Any? {
switch type {
case 129:
return ClientCertData.fromList(self.readValue() as! [Any?])
case 130:
return ClientCertPrompt.fromList(self.readValue() as! [Any?])
default:
return super.readValue(ofType: type)
}
}
}
private class NetworkPigeonCodecWriter: FlutterStandardWriter {
override func writeValue(_ value: Any) {
if let value = value as? ClientCertData {
super.writeByte(129)
super.writeValue(value.toList())
} else if let value = value as? ClientCertPrompt {
super.writeByte(130)
super.writeValue(value.toList())
} else {
super.writeValue(value)
}
}
}
private class NetworkPigeonCodecReaderWriter: FlutterStandardReaderWriter {
override func reader(with data: Data) -> FlutterStandardReader {
return NetworkPigeonCodecReader(data: data)
}
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
return NetworkPigeonCodecWriter(data: data)
}
}
class NetworkPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
static let shared = NetworkPigeonCodec(readerWriter: NetworkPigeonCodecReaderWriter())
}
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol NetworkApi {
func addCertificate(clientData: ClientCertData, completion: @escaping (Result<Void, Error>) -> Void)
func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<Void, Error>) -> Void)
func removeCertificate(completion: @escaping (Result<Void, Error>) -> Void)
func hasCertificate() throws -> Bool
func getClientPointer() throws -> Int64
func setRequestHeaders(headers: [String: String], serverUrls: [String], token: String?) throws
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class NetworkApiSetup {
static var codec: FlutterStandardMessageCodec { NetworkPigeonCodec.shared }
/// Sets up an instance of `NetworkApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: NetworkApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
let addCertificateChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.addCertificate\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
addCertificateChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let clientDataArg = args[0] as! ClientCertData
api.addCertificate(clientData: clientDataArg) { result in
switch result {
case .success:
reply(wrapResult(nil))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
addCertificateChannel.setMessageHandler(nil)
}
let selectCertificateChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.selectCertificate\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
selectCertificateChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let promptTextArg = args[0] as! ClientCertPrompt
api.selectCertificate(promptText: promptTextArg) { result in
switch result {
case .success:
reply(wrapResult(nil))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
selectCertificateChannel.setMessageHandler(nil)
}
let removeCertificateChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.removeCertificate\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
removeCertificateChannel.setMessageHandler { _, reply in
api.removeCertificate { result in
switch result {
case .success:
reply(wrapResult(nil))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
removeCertificateChannel.setMessageHandler(nil)
}
let hasCertificateChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.hasCertificate\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
hasCertificateChannel.setMessageHandler { _, reply in
do {
let result = try api.hasCertificate()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
hasCertificateChannel.setMessageHandler(nil)
}
let getClientPointerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
getClientPointerChannel.setMessageHandler { _, reply in
do {
let result = try api.getClientPointer()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
getClientPointerChannel.setMessageHandler(nil)
}
let setRequestHeadersChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
setRequestHeadersChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let headersArg = args[0] as! [String: String]
let serverUrlsArg = args[1] as! [String]
let tokenArg: String? = nilOrValue(args[2])
do {
try api.setRequestHeaders(headers: headersArg, serverUrls: serverUrlsArg, token: tokenArg)
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
setRequestHeadersChannel.setMessageHandler(nil)
}
}
}
+210
View File
@@ -0,0 +1,210 @@
import Foundation
import UniformTypeIdentifiers
import native_video_player
enum ImportError: Error {
case noFile
case noViewController
case keychainError(OSStatus)
case cancelled
}
class NetworkApiImpl: NetworkApi {
weak var viewController: UIViewController?
private var activeImporter: CertImporter?
init(viewController: UIViewController?) {
self.viewController = viewController
}
func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<Void, any Error>) -> Void) {
let importer = CertImporter(promptText: promptText, completion: { [weak self] result in
self?.activeImporter = nil
completion(result)
}, viewController: viewController)
activeImporter = importer
importer.load()
}
func hasCertificate() throws -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: CLIENT_CERT_LABEL,
kSecReturnRef as String: true,
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
return status == errSecSuccess
}
func removeCertificate(completion: @escaping (Result<Void, any Error>) -> Void) {
let status = clearCerts()
if status == errSecSuccess || status == errSecItemNotFound {
return completion(.success(()))
}
completion(.failure(ImportError.keychainError(status)))
}
func addCertificate(clientData: ClientCertData, completion: @escaping (Result<Void, any Error>) -> Void) {
let status = importCert(clientData: clientData.data.data, password: clientData.password)
if status == errSecSuccess {
return completion(.success(()))
}
completion(.failure(ImportError.keychainError(status)))
}
func getClientPointer() throws -> Int64 {
let pointer = URLSessionManager.shared.sessionPointer
return Int64(Int(bitPattern: pointer))
}
func setRequestHeaders(headers: [String : String], serverUrls: [String], token: String?) throws {
URLSessionManager.setServerUrls(serverUrls)
if let token = token {
let expiry = Date().addingTimeInterval(COOKIE_EXPIRY_DAYS * 24 * 60 * 60)
for serverUrl in serverUrls {
guard let url = URL(string: serverUrl), let domain = url.host else { continue }
let isSecure = serverUrl.hasPrefix("https")
let values: [AuthCookie: String] = [
.accessToken: token,
.isAuthenticated: "true",
.authType: "password",
]
for (cookie, value) in values {
var properties: [HTTPCookiePropertyKey: Any] = [
.name: cookie.name,
.value: value,
.domain: domain,
.path: "/",
.expires: expiry,
]
if isSecure { properties[.secure] = "TRUE" }
if cookie.httpOnly { properties[.init("HttpOnly")] = "TRUE" }
if let httpCookie = HTTPCookie(properties: properties) {
URLSessionManager.cookieStorage.setCookie(httpCookie)
}
}
}
}
if headers != UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] {
UserDefaults.group.set(headers, forKey: HEADERS_KEY)
URLSessionManager.shared.recreateSession()
}
}
}
private class CertImporter: NSObject, UIDocumentPickerDelegate {
private let promptText: ClientCertPrompt
private var completion: ((Result<Void, Error>) -> Void)
private weak var viewController: UIViewController?
init(promptText: ClientCertPrompt, completion: (@escaping (Result<Void, Error>) -> Void), viewController: UIViewController?) {
self.promptText = promptText
self.completion = completion
self.viewController = viewController
}
func load() {
guard let vc = viewController else { return completion(.failure(ImportError.noViewController)) }
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [
UTType(filenameExtension: "p12")!,
UTType(filenameExtension: "pfx")!,
])
picker.delegate = self
picker.allowsMultipleSelection = false
vc.present(picker, animated: true)
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first else {
return completion(.failure(ImportError.noFile))
}
Task { @MainActor in
do {
let data = try readSecurityScoped(url: url)
guard let password = await promptForPassword() else {
return completion(.failure(ImportError.cancelled))
}
let status = importCert(clientData: data, password: password)
if status != errSecSuccess {
return completion(.failure(ImportError.keychainError(status)))
}
await URLSessionManager.shared.session.flush()
self.completion(.success(()))
} catch {
completion(.failure(error))
}
}
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
completion(.failure(ImportError.cancelled))
}
private func promptForPassword() async -> String? {
guard let vc = viewController else { return nil }
return await withCheckedContinuation { continuation in
let alert = UIAlertController(
title: promptText.title,
message: promptText.message,
preferredStyle: .alert
)
alert.addTextField { $0.isSecureTextEntry = true }
alert.addAction(UIAlertAction(title: promptText.cancel, style: .cancel) { _ in
continuation.resume(returning: nil)
})
alert.addAction(UIAlertAction(title: promptText.confirm, style: .default) { _ in
continuation.resume(returning: alert.textFields?.first?.text ?? "")
})
vc.present(alert, animated: true)
}
}
private func readSecurityScoped(url: URL) throws -> Data {
guard url.startAccessingSecurityScopedResource() else {
throw ImportError.noFile
}
defer { url.stopAccessingSecurityScopedResource() }
return try Data(contentsOf: url)
}
}
private func importCert(clientData: Data, password: String) -> OSStatus {
let options = [kSecImportExportPassphrase: password] as CFDictionary
var items: CFArray?
let status = SecPKCS12Import(clientData as CFData, options, &items)
guard status == errSecSuccess,
let array = items as? [[String: Any]],
let first = array.first,
let identity = first[kSecImportItemIdentity as String] else {
return status
}
clearCerts()
let addQuery: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecValueRef as String: identity,
kSecAttrLabel as String: CLIENT_CERT_LABEL,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
return SecItemAdd(addQuery as CFDictionary, nil)
}
@discardableResult private func clearCerts() -> OSStatus {
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: CLIENT_CERT_LABEL,
]
return SecItemDelete(deleteQuery as CFDictionary)
}
@@ -0,0 +1,277 @@
import Foundation
import native_video_player
let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity"
let HEADERS_KEY = "immich.request_headers"
let SERVER_URLS_KEY = "immich.server_urls"
let APP_GROUP = "group.app.immich.share"
let COOKIE_EXPIRY_DAYS: TimeInterval = 400
enum AuthCookie: CaseIterable {
case accessToken, isAuthenticated, authType
var name: String {
switch self {
case .accessToken: return "immich_access_token"
case .isAuthenticated: return "immich_is_authenticated"
case .authType: return "immich_auth_type"
}
}
var httpOnly: Bool {
switch self {
case .accessToken, .authType: return true
case .isAuthenticated: return false
}
}
static let names: Set<String> = Set(allCases.map(\.name))
}
extension UserDefaults {
static let group = UserDefaults(suiteName: APP_GROUP)!
}
/// Manages a shared URLSession with SSL configuration support.
/// Old sessions are kept alive by Dart's FFI retain until all isolates release them.
class URLSessionManager: NSObject {
static let shared = URLSessionManager()
private(set) var session: URLSession
let delegate: URLSessionManagerDelegate
private static let cacheDir: URL = {
let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
.first!
.appendingPathComponent("api", isDirectory: true)
try! FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}()
private static let urlCache = URLCache(
memoryCapacity: 0,
diskCapacity: 1024 * 1024 * 1024,
directory: cacheDir
)
static let userAgent: String = {
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
return "Immich_iOS_\(version)"
}()
static let cookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: APP_GROUP)
private static var serverUrls: [String] = []
private static var isSyncing = false
var sessionPointer: UnsafeMutableRawPointer {
Unmanaged.passUnretained(session).toOpaque()
}
private override init() {
delegate = URLSessionManagerDelegate()
session = Self.buildSession(delegate: delegate)
super.init()
Self.serverUrls = UserDefaults.group.stringArray(forKey: SERVER_URLS_KEY) ?? []
NotificationCenter.default.addObserver(
Self.self,
selector: #selector(Self.cookiesDidChange),
name: NSNotification.Name.NSHTTPCookieManagerCookiesChanged,
object: Self.cookieStorage
)
}
func recreateSession() {
session = Self.buildSession(delegate: delegate)
}
static func setServerUrls(_ urls: [String]) {
guard urls != serverUrls else { return }
serverUrls = urls
UserDefaults.group.set(urls, forKey: SERVER_URLS_KEY)
syncAuthCookies()
}
@objc private static func cookiesDidChange(_ notification: Notification) {
guard !isSyncing, !serverUrls.isEmpty else { return }
syncAuthCookies()
}
private static func syncAuthCookies() {
let serverHosts = Set(serverUrls.compactMap { URL(string: $0)?.host })
let allCookies = cookieStorage.cookies ?? []
let now = Date()
let serverAuthCookies = allCookies.filter {
AuthCookie.names.contains($0.name) && serverHosts.contains($0.domain)
}
var sourceCookies: [String: HTTPCookie] = [:]
for cookie in serverAuthCookies {
if cookie.expiresDate.map({ $0 > now }) ?? true {
sourceCookies[cookie.name] = cookie
}
}
isSyncing = true
defer { isSyncing = false }
if sourceCookies.isEmpty {
for cookie in serverAuthCookies {
cookieStorage.deleteCookie(cookie)
}
return
}
for serverUrl in serverUrls {
guard let url = URL(string: serverUrl), let domain = url.host else { continue }
let isSecure = serverUrl.hasPrefix("https")
for (_, source) in sourceCookies {
if allCookies.contains(where: { $0.name == source.name && $0.domain == domain && $0.value == source.value }) {
continue
}
var properties: [HTTPCookiePropertyKey: Any] = [
.name: source.name,
.value: source.value,
.domain: domain,
.path: "/",
.expires: source.expiresDate ?? Date().addingTimeInterval(COOKIE_EXPIRY_DAYS * 24 * 60 * 60),
]
if isSecure { properties[.secure] = "TRUE" }
if source.isHTTPOnly { properties[.init("HttpOnly")] = "TRUE" }
if let cookie = HTTPCookie(properties: properties) {
cookieStorage.setCookie(cookie)
}
}
}
}
private static func buildSession(delegate: URLSessionManagerDelegate) -> URLSession {
let config = URLSessionConfiguration.default
config.urlCache = urlCache
config.httpCookieStorage = cookieStorage
config.httpMaximumConnectionsPerHost = 64
config.timeoutIntervalForRequest = 60
var headers = UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] ?? [:]
headers["User-Agent"] = headers["User-Agent"] ?? userAgent
config.httpAdditionalHeaders = headers
return URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
}
/// Patches background_downloader's URLSession to use shared auth configuration.
/// Must be called before background_downloader creates its session (i.e. early in app startup).
static func patchBackgroundDownloader() {
// Swizzle URLSessionConfiguration.background(withIdentifier:) to inject shared config
let originalSel = NSSelectorFromString("backgroundSessionConfigurationWithIdentifier:")
let swizzledSel = #selector(URLSessionConfiguration.immich_background(withIdentifier:))
if let original = class_getClassMethod(URLSessionConfiguration.self, originalSel),
let swizzled = class_getClassMethod(URLSessionConfiguration.self, swizzledSel) {
method_exchangeImplementations(original, swizzled)
}
// Add auth challenge handling to background_downloader's UrlSessionDelegate
guard let targetClass = NSClassFromString("background_downloader.UrlSessionDelegate") else { return }
let sessionBlock: @convention(block) (AnyObject, URLSession, URLAuthenticationChallenge,
@escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Void
= { _, session, challenge, completion in
URLSessionManager.shared.delegate.handleChallenge(session, challenge, completion)
}
class_replaceMethod(targetClass,
NSSelectorFromString("URLSession:didReceiveChallenge:completionHandler:"),
imp_implementationWithBlock(sessionBlock), "v@:@@@?")
let taskBlock: @convention(block) (AnyObject, URLSession, URLSessionTask, URLAuthenticationChallenge,
@escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Void
= { _, session, task, challenge, completion in
URLSessionManager.shared.delegate.handleChallenge(session, challenge, completion, task: task)
}
class_replaceMethod(targetClass,
NSSelectorFromString("URLSession:task:didReceiveChallenge:completionHandler:"),
imp_implementationWithBlock(taskBlock), "v@:@@@@?")
}
}
private extension URLSessionConfiguration {
@objc dynamic class func immich_background(withIdentifier id: String) -> URLSessionConfiguration {
// After swizzle, this calls the original implementation
let config = immich_background(withIdentifier: id)
config.httpCookieStorage = URLSessionManager.cookieStorage
config.httpAdditionalHeaders = ["User-Agent": URLSessionManager.userAgent]
return config
}
}
class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWebSocketDelegate {
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
handleChallenge(session, challenge, completionHandler)
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
handleChallenge(session, challenge, completionHandler, task: task)
}
func handleChallenge(
_ session: URLSession,
_ challenge: URLAuthenticationChallenge,
_ completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void,
task: URLSessionTask? = nil
) {
switch challenge.protectionSpace.authenticationMethod {
case NSURLAuthenticationMethodClientCertificate: handleClientCertificate(session, completion: completionHandler)
case NSURLAuthenticationMethodHTTPBasic: handleBasicAuth(session, task: task, completion: completionHandler)
default: completionHandler(.performDefaultHandling, nil)
}
}
private func handleClientCertificate(
_ session: URLSession,
completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
let query: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: CLIENT_CERT_LABEL,
kSecReturnRef as String: true,
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status == errSecSuccess, let identity = item {
let credential = URLCredential(identity: identity as! SecIdentity,
certificates: nil,
persistence: .forSession)
if #available(iOS 15, *) {
VideoProxyServer.shared.session = session
}
return completion(.useCredential, credential)
}
completion(.performDefaultHandling, nil)
}
private func handleBasicAuth(
_ session: URLSession,
task: URLSessionTask?,
completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard let url = task?.originalRequest?.url,
let user = url.user,
let password = url.password
else {
return completion(.performDefaultHandling, nil)
}
if #available(iOS 15, *) {
VideoProxyServer.shared.session = session
}
let credential = URLCredential(user: user, password: password, persistence: .forSession)
completion(.useCredential, credential)
}
}
@@ -0,0 +1,7 @@
import Foundation
enum ImageProcessing {
static let queue = DispatchQueue(label: "thumbnail.processing", qos: .userInitiated, attributes: .concurrent)
static let semaphore = DispatchSemaphore(value: ProcessInfo.processInfo.activeProcessorCount * 2)
static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil)
}
@@ -47,41 +47,41 @@ private func nilOrValue<T>(_ value: Any?) -> T? {
}
private class ThumbnailsPigeonCodecReader: FlutterStandardReader {
private class LocalImagesPigeonCodecReader: FlutterStandardReader {
}
private class ThumbnailsPigeonCodecWriter: FlutterStandardWriter {
private class LocalImagesPigeonCodecWriter: FlutterStandardWriter {
}
private class ThumbnailsPigeonCodecReaderWriter: FlutterStandardReaderWriter {
private class LocalImagesPigeonCodecReaderWriter: FlutterStandardReaderWriter {
override func reader(with data: Data) -> FlutterStandardReader {
return ThumbnailsPigeonCodecReader(data: data)
return LocalImagesPigeonCodecReader(data: data)
}
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
return ThumbnailsPigeonCodecWriter(data: data)
return LocalImagesPigeonCodecWriter(data: data)
}
}
class ThumbnailsPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
static let shared = ThumbnailsPigeonCodec(readerWriter: ThumbnailsPigeonCodecReaderWriter())
class LocalImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
static let shared = LocalImagesPigeonCodec(readerWriter: LocalImagesPigeonCodecReaderWriter())
}
/// 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
protocol LocalImageApi {
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
func cancelRequest(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 = "") {
class LocalImageApiSetup {
static var codec: FlutterStandardMessageCodec { LocalImagesPigeonCodec.shared }
/// Sets up an instance of `LocalImageApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: LocalImageApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
let requestImageChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ThumbnailApi.requestImage\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
let requestImageChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
requestImageChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
@@ -90,7 +90,8 @@ class ThumbnailApiSetup {
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
let preferEncodedArg = args[5] as! Bool
api.requestImage(assetId: assetIdArg, requestId: requestIdArg, width: widthArg, height: heightArg, isVideo: isVideoArg, preferEncoded: preferEncodedArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
@@ -102,22 +103,22 @@ class ThumbnailApiSetup {
} else {
requestImageChannel.setMessageHandler(nil)
}
let cancelImageRequestChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ThumbnailApi.cancelImageRequest\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
let cancelRequestChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.LocalImageApi.cancelRequest\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
cancelImageRequestChannel.setMessageHandler { message, reply in
cancelRequestChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let requestIdArg = args[0] as! Int64
do {
try api.cancelImageRequest(requestId: requestIdArg)
try api.cancelRequest(requestId: requestIdArg)
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
cancelImageRequestChannel.setMessageHandler(nil)
cancelRequestChannel.setMessageHandler(nil)
}
let getThumbhashChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ThumbnailApi.getThumbhash\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
let getThumbhashChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.LocalImageApi.getThumbhash\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
getThumbhashChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
@@ -0,0 +1,221 @@
import Accelerate
import Flutter
import MobileCoreServices
import Photos
class LocalImageRequest {
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 LocalImageApiImpl: LocalImageApi {
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 var rgbaFormat = vImage_CGImageFormat(
bitsPerComponent: 8,
bitsPerPixel: 32,
colorSpace: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
renderingIntent: .defaultIntent
)!
private static var requests = [Int64: LocalImageRequest]()
private static let assetCache = {
let assetCache = NSCache<NSString, PHAsset>()
assetCache.countLimit = 10000
return assetCache
}()
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
ImageProcessing.queue.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)
completion(.success([
"pointer": Int64(Int(bitPattern: pointer.baseAddress)),
"width": Int64(width),
"height": Int64(height),
"rowBytes": Int64(width * 4)
]))
}
}
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
let request = LocalImageRequest(callback: completion)
let item = DispatchWorkItem {
if request.isCancelled {
return completion(ImageProcessing.cancelledResult)
}
ImageProcessing.semaphore.wait()
defer {
ImageProcessing.semaphore.signal()
}
if request.isCancelled {
return completion(ImageProcessing.cancelledResult)
}
guard let asset = Self.requestAsset(assetId: assetId)
else {
Self.remove(requestId: requestId)
completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil)))
return
}
if request.isCancelled {
return completion(ImageProcessing.cancelledResult)
}
if preferEncoded {
let dataOptions = PHImageRequestOptions()
dataOptions.isNetworkAccessAllowed = true
dataOptions.isSynchronous = true
dataOptions.version = .current
var imageData: Data?
Self.imageManager.requestImageDataAndOrientation(
for: asset,
options: dataOptions,
resultHandler: { (data, _, _, _) in
imageData = data
}
)
if request.isCancelled {
Self.remove(requestId: requestId)
return completion(ImageProcessing.cancelledResult)
}
guard let data = imageData else {
Self.remove(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not get image data for \(assetId)", details: nil)))
}
let length = data.count
let pointer = malloc(length)!
data.copyBytes(to: pointer.assumingMemoryBound(to: UInt8.self), count: length)
if request.isCancelled {
free(pointer)
Self.remove(requestId: requestId)
return completion(ImageProcessing.cancelledResult)
}
request.callback(.success([
"pointer": Int64(Int(bitPattern: pointer)),
"length": Int64(length),
]))
Self.remove(requestId: requestId)
return
}
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(ImageProcessing.cancelledResult)
}
guard let image = image,
let cgImage = image.cgImage else {
Self.remove(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
}
if request.isCancelled {
return completion(ImageProcessing.cancelledResult)
}
do {
let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat)
if request.isCancelled {
buffer.free()
return completion(ImageProcessing.cancelledResult)
}
request.callback(.success([
"pointer": Int64(Int(bitPattern: buffer.data)),
"width": Int64(buffer.width),
"height": Int64(buffer.height),
"rowBytes": Int64(buffer.rowBytes)
]))
Self.remove(requestId: requestId)
} catch {
Self.remove(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil)))
}
}
request.workItem = item
Self.add(requestId: requestId, request: request)
ImageProcessing.queue.async(execute: item)
}
func cancelRequest(requestId: Int64) {
Self.cancel(requestId: requestId)
}
private static func add(requestId: Int64, request: LocalImageRequest) -> Void {
requestQueue.sync { requests[requestId] = request }
}
private static func remove(requestId: Int64) -> Void {
requestQueue.sync { requests[requestId] = nil }
}
private static func cancel(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(ImageProcessing.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
}
}
@@ -0,0 +1,134 @@
// Autogenerated from Pigeon (v26.0.2), 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 RemoteImagesPigeonCodecReader: FlutterStandardReader {
}
private class RemoteImagesPigeonCodecWriter: FlutterStandardWriter {
}
private class RemoteImagesPigeonCodecReaderWriter: FlutterStandardReaderWriter {
override func reader(with data: Data) -> FlutterStandardReader {
return RemoteImagesPigeonCodecReader(data: data)
}
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
return RemoteImagesPigeonCodecWriter(data: data)
}
}
class RemoteImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
static let shared = RemoteImagesPigeonCodec(readerWriter: RemoteImagesPigeonCodecReaderWriter())
}
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol RemoteImageApi {
func requestImage(url: String, requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
func cancelRequest(requestId: Int64) throws
func clearCache(completion: @escaping (Result<Int64, Error>) -> Void)
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class RemoteImageApiSetup {
static var codec: FlutterStandardMessageCodec { RemoteImagesPigeonCodec.shared }
/// Sets up an instance of `RemoteImageApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: RemoteImageApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
let requestImageChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
requestImageChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let urlArg = args[0] as! String
let requestIdArg = args[1] as! Int64
let preferEncodedArg = args[2] as! Bool
api.requestImage(url: urlArg, requestId: requestIdArg, preferEncoded: preferEncodedArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
requestImageChannel.setMessageHandler(nil)
}
let cancelRequestChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.RemoteImageApi.cancelRequest\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
cancelRequestChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let requestIdArg = args[0] as! Int64
do {
try api.cancelRequest(requestId: requestIdArg)
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
cancelRequestChannel.setMessageHandler(nil)
}
let clearCacheChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.RemoteImageApi.clearCache\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
clearCacheChannel.setMessageHandler { _, reply in
api.clearCache { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
clearCacheChannel.setMessageHandler(nil)
}
}
}
@@ -0,0 +1,150 @@
import Accelerate
import Flutter
import MobileCoreServices
import Photos
class RemoteImageRequest {
weak var task: URLSessionDataTask?
let id: Int64
var isCancelled = false
let completion: (Result<[String: Int64]?, any Error>) -> Void
init(id: Int64, task: URLSessionDataTask, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
self.id = id
self.task = task
self.completion = completion
}
}
class RemoteImageApiImpl: NSObject, RemoteImageApi {
private static var lock = os_unfair_lock()
private static var requests = [Int64: RemoteImageRequest]()
private static var rgbaFormat = vImage_CGImageFormat(
bitsPerComponent: 8,
bitsPerPixel: 32,
colorSpace: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
renderingIntent: .perceptual
)!
private static let decodeOptions = [
kCGImageSourceShouldCache: false,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceCreateThumbnailFromImageAlways: true
] as CFDictionary
func requestImage(url: String, requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
var urlRequest = URLRequest(url: URL(string: url)!)
urlRequest.cachePolicy = .returnCacheDataElseLoad
let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in
Self.handleCompletion(requestId: requestId, encoded: preferEncoded, data: data, response: response, error: error)
}
let request = RemoteImageRequest(id: requestId, task: task, completion: completion)
os_unfair_lock_lock(&Self.lock)
Self.requests[requestId] = request
os_unfair_lock_unlock(&Self.lock)
task.resume()
}
private static func handleCompletion(requestId: Int64, encoded: Bool, data: Data?, response: URLResponse?, error: Error?) {
os_unfair_lock_lock(&Self.lock)
guard let request = requests[requestId] else {
return os_unfair_lock_unlock(&Self.lock)
}
requests[requestId] = nil
os_unfair_lock_unlock(&Self.lock)
if let error = error {
if request.isCancelled || (error as NSError).code == NSURLErrorCancelled {
return request.completion(ImageProcessing.cancelledResult)
}
return request.completion(.failure(error))
}
if request.isCancelled {
return request.completion(ImageProcessing.cancelledResult)
}
guard let data = data else {
return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil)))
}
ImageProcessing.queue.async {
ImageProcessing.semaphore.wait()
defer { ImageProcessing.semaphore.signal() }
if request.isCancelled {
return request.completion(ImageProcessing.cancelledResult)
}
// Return raw encoded bytes when requested (for animated images)
if encoded {
let length = data.count
let pointer = malloc(length)!
data.copyBytes(to: pointer.assumingMemoryBound(to: UInt8.self), count: length)
if request.isCancelled {
free(pointer)
return request.completion(ImageProcessing.cancelledResult)
}
return request.completion(
.success([
"pointer": Int64(Int(bitPattern: pointer)),
"length": Int64(length),
]))
}
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil),
let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions) else {
return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil)))
}
if request.isCancelled {
return request.completion(ImageProcessing.cancelledResult)
}
do {
let buffer = try vImage_Buffer(cgImage: cgImage, format: rgbaFormat)
if request.isCancelled {
buffer.free()
return request.completion(ImageProcessing.cancelledResult)
}
request.completion(
.success([
"pointer": Int64(Int(bitPattern: buffer.data)),
"width": Int64(buffer.width),
"height": Int64(buffer.height),
"rowBytes": Int64(buffer.rowBytes),
]))
} catch {
return request.completion(.failure(PigeonError(code: "", message: "Failed to convert image for request: \(error)", details: nil)))
}
}
}
func cancelRequest(requestId: Int64) {
os_unfair_lock_lock(&Self.lock)
let request = Self.requests[requestId]
os_unfair_lock_unlock(&Self.lock)
guard let request = request else { return }
request.isCancelled = true
request.task?.cancel()
}
func clearCache(completion: @escaping (Result<Int64, any Error>) -> Void) {
Task {
let cache = URLSessionManager.shared.session.configuration.urlCache!
let cacheSize = Int64(cache.currentDiskUsage)
cache.removeAllCachedResponses()
completion(.success(cacheSize))
}
}
}
@@ -1,211 +0,0 @@
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()
}
}
+3 -4
View File
@@ -80,7 +80,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.2.1</string>
<string>2.6.3</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@@ -107,7 +107,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>233</string>
<string>240</string>
<key>FLTEnableImpeller</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
@@ -137,8 +137,7 @@
<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>
<string>We need local network permission to connect to the local server using IP address and allow the casting feature to work</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
<key>NSLocationUsageDescription</key>
+177
View File
@@ -0,0 +1,177 @@
import SQLiteData
struct Endpoint: Codable {
let url: URL
let status: Status
enum Status: String, Codable {
case loading, valid, error, unknown
}
}
enum StoreKey: Int, CaseIterable, QueryBindable {
// MARK: - Int
case _version = 0
static let version = Typed<Int>(rawValue: ._version)
case _deviceIdHash = 3
static let deviceIdHash = Typed<Int>(rawValue: ._deviceIdHash)
case _backupTriggerDelay = 8
static let backupTriggerDelay = Typed<Int>(rawValue: ._backupTriggerDelay)
case _tilesPerRow = 103
static let tilesPerRow = Typed<Int>(rawValue: ._tilesPerRow)
case _groupAssetsBy = 105
static let groupAssetsBy = Typed<Int>(rawValue: ._groupAssetsBy)
case _uploadErrorNotificationGracePeriod = 106
static let uploadErrorNotificationGracePeriod = Typed<Int>(rawValue: ._uploadErrorNotificationGracePeriod)
case _thumbnailCacheSize = 110
static let thumbnailCacheSize = Typed<Int>(rawValue: ._thumbnailCacheSize)
case _imageCacheSize = 111
static let imageCacheSize = Typed<Int>(rawValue: ._imageCacheSize)
case _albumThumbnailCacheSize = 112
static let albumThumbnailCacheSize = Typed<Int>(rawValue: ._albumThumbnailCacheSize)
case _selectedAlbumSortOrder = 113
static let selectedAlbumSortOrder = Typed<Int>(rawValue: ._selectedAlbumSortOrder)
case _logLevel = 115
static let logLevel = Typed<Int>(rawValue: ._logLevel)
case _mapRelativeDate = 119
static let mapRelativeDate = Typed<Int>(rawValue: ._mapRelativeDate)
case _mapThemeMode = 124
static let mapThemeMode = Typed<Int>(rawValue: ._mapThemeMode)
// MARK: - String
case _assetETag = 1
static let assetETag = Typed<String>(rawValue: ._assetETag)
case _currentUser = 2
static let currentUser = Typed<String>(rawValue: ._currentUser)
case _deviceId = 4
static let deviceId = Typed<String>(rawValue: ._deviceId)
case _accessToken = 11
static let accessToken = Typed<String>(rawValue: ._accessToken)
case _serverEndpoint = 12
static let serverEndpoint = Typed<String>(rawValue: ._serverEndpoint)
case _sslClientCertData = 15
static let sslClientCertData = Typed<String>(rawValue: ._sslClientCertData)
case _sslClientPasswd = 16
static let sslClientPasswd = Typed<String>(rawValue: ._sslClientPasswd)
case _themeMode = 102
static let themeMode = Typed<String>(rawValue: ._themeMode)
case _customHeaders = 127
static let customHeaders = Typed<[String: String]>(rawValue: ._customHeaders)
case _primaryColor = 128
static let primaryColor = Typed<String>(rawValue: ._primaryColor)
case _preferredWifiName = 133
static let preferredWifiName = Typed<String>(rawValue: ._preferredWifiName)
// MARK: - Endpoint
case _externalEndpointList = 135
static let externalEndpointList = Typed<[Endpoint]>(rawValue: ._externalEndpointList)
// MARK: - URL
case _localEndpoint = 134
static let localEndpoint = Typed<URL>(rawValue: ._localEndpoint)
case _serverUrl = 10
static let serverUrl = Typed<URL>(rawValue: ._serverUrl)
// MARK: - Date
case _backupFailedSince = 5
static let backupFailedSince = Typed<Date>(rawValue: ._backupFailedSince)
// MARK: - Bool
case _backupRequireWifi = 6
static let backupRequireWifi = Typed<Bool>(rawValue: ._backupRequireWifi)
case _backupRequireCharging = 7
static let backupRequireCharging = Typed<Bool>(rawValue: ._backupRequireCharging)
case _autoBackup = 13
static let autoBackup = Typed<Bool>(rawValue: ._autoBackup)
case _backgroundBackup = 14
static let backgroundBackup = Typed<Bool>(rawValue: ._backgroundBackup)
case _loadPreview = 100
static let loadPreview = Typed<Bool>(rawValue: ._loadPreview)
case _loadOriginal = 101
static let loadOriginal = Typed<Bool>(rawValue: ._loadOriginal)
case _dynamicLayout = 104
static let dynamicLayout = Typed<Bool>(rawValue: ._dynamicLayout)
case _backgroundBackupTotalProgress = 107
static let backgroundBackupTotalProgress = Typed<Bool>(rawValue: ._backgroundBackupTotalProgress)
case _backgroundBackupSingleProgress = 108
static let backgroundBackupSingleProgress = Typed<Bool>(rawValue: ._backgroundBackupSingleProgress)
case _storageIndicator = 109
static let storageIndicator = Typed<Bool>(rawValue: ._storageIndicator)
case _advancedTroubleshooting = 114
static let advancedTroubleshooting = Typed<Bool>(rawValue: ._advancedTroubleshooting)
case _preferRemoteImage = 116
static let preferRemoteImage = Typed<Bool>(rawValue: ._preferRemoteImage)
case _loopVideo = 117
static let loopVideo = Typed<Bool>(rawValue: ._loopVideo)
case _mapShowFavoriteOnly = 118
static let mapShowFavoriteOnly = Typed<Bool>(rawValue: ._mapShowFavoriteOnly)
case _selfSignedCert = 120
static let selfSignedCert = Typed<Bool>(rawValue: ._selfSignedCert)
case _mapIncludeArchived = 121
static let mapIncludeArchived = Typed<Bool>(rawValue: ._mapIncludeArchived)
case _ignoreIcloudAssets = 122
static let ignoreIcloudAssets = Typed<Bool>(rawValue: ._ignoreIcloudAssets)
case _selectedAlbumSortReverse = 123
static let selectedAlbumSortReverse = Typed<Bool>(rawValue: ._selectedAlbumSortReverse)
case _mapwithPartners = 125
static let mapwithPartners = Typed<Bool>(rawValue: ._mapwithPartners)
case _enableHapticFeedback = 126
static let enableHapticFeedback = Typed<Bool>(rawValue: ._enableHapticFeedback)
case _dynamicTheme = 129
static let dynamicTheme = Typed<Bool>(rawValue: ._dynamicTheme)
case _colorfulInterface = 130
static let colorfulInterface = Typed<Bool>(rawValue: ._colorfulInterface)
case _syncAlbums = 131
static let syncAlbums = Typed<Bool>(rawValue: ._syncAlbums)
case _autoEndpointSwitching = 132
static let autoEndpointSwitching = Typed<Bool>(rawValue: ._autoEndpointSwitching)
case _loadOriginalVideo = 136
static let loadOriginalVideo = Typed<Bool>(rawValue: ._loadOriginalVideo)
case _manageLocalMediaAndroid = 137
static let manageLocalMediaAndroid = Typed<Bool>(rawValue: ._manageLocalMediaAndroid)
case _readonlyModeEnabled = 138
static let readonlyModeEnabled = Typed<Bool>(rawValue: ._readonlyModeEnabled)
case _autoPlayVideo = 139
static let autoPlayVideo = Typed<Bool>(rawValue: ._autoPlayVideo)
case _photoManagerCustomFilter = 1000
static let photoManagerCustomFilter = Typed<Bool>(rawValue: ._photoManagerCustomFilter)
case _betaPromptShown = 1001
static let betaPromptShown = Typed<Bool>(rawValue: ._betaPromptShown)
case _betaTimeline = 1002
static let betaTimeline = Typed<Bool>(rawValue: ._betaTimeline)
case _enableBackup = 1003
static let enableBackup = Typed<Bool>(rawValue: ._enableBackup)
case _useWifiForUploadVideos = 1004
static let useWifiForUploadVideos = Typed<Bool>(rawValue: ._useWifiForUploadVideos)
case _useWifiForUploadPhotos = 1005
static let useWifiForUploadPhotos = Typed<Bool>(rawValue: ._useWifiForUploadPhotos)
case _needBetaMigration = 1006
static let needBetaMigration = Typed<Bool>(rawValue: ._needBetaMigration)
case _shouldResetSync = 1007
static let shouldResetSync = Typed<Bool>(rawValue: ._shouldResetSync)
struct Typed<T>: RawRepresentable {
let rawValue: StoreKey
@_transparent
init(rawValue value: StoreKey) {
self.rawValue = value
}
}
}
enum BackupSelection: Int, QueryBindable {
case selected, none, excluded
}
enum AvatarColor: Int, QueryBindable {
case primary, pink, red, yellow, blue, green, purple, orange, gray, amber
}
enum AlbumUserRole: Int, QueryBindable {
case editor, viewer
}
enum MemoryType: Int, QueryBindable {
case onThisDay
}
+146
View File
@@ -0,0 +1,146 @@
import SQLiteData
enum StoreError: Error {
case invalidJSON(String)
case invalidURL(String)
case encodingFailed
}
protocol StoreConvertible {
associatedtype StorageType
static func fromValue(_ value: StorageType) throws(StoreError) -> Self
static func toValue(_ value: Self) throws(StoreError) -> StorageType
}
extension Int: StoreConvertible {
static func fromValue(_ value: Int) -> Int { value }
static func toValue(_ value: Int) -> Int { value }
}
extension Bool: StoreConvertible {
static func fromValue(_ value: Int) -> Bool { value == 1 }
static func toValue(_ value: Bool) -> Int { value ? 1 : 0 }
}
extension Date: StoreConvertible {
static func fromValue(_ value: Int) -> Date { Date(timeIntervalSince1970: TimeInterval(value) / 1000) }
static func toValue(_ value: Date) -> Int { Int(value.timeIntervalSince1970 * 1000) }
}
extension String: StoreConvertible {
static func fromValue(_ value: String) -> String { value }
static func toValue(_ value: String) -> String { value }
}
extension URL: StoreConvertible {
static func fromValue(_ value: String) throws(StoreError) -> URL {
guard let url = URL(string: value) else {
throw StoreError.invalidURL(value)
}
return url
}
static func toValue(_ value: URL) -> String { value.absoluteString }
}
extension StoreConvertible where Self: Codable, StorageType == String {
static var jsonDecoder: JSONDecoder { JSONDecoder() }
static var jsonEncoder: JSONEncoder { JSONEncoder() }
static func fromValue(_ value: String) throws(StoreError) -> Self {
do {
return try jsonDecoder.decode(Self.self, from: Data(value.utf8))
} catch {
throw StoreError.invalidJSON(value)
}
}
static func toValue(_ value: Self) throws(StoreError) -> String {
let encoded: Data
do {
encoded = try jsonEncoder.encode(value)
} catch {
throw StoreError.encodingFailed
}
guard let string = String(data: encoded, encoding: .utf8) else {
throw StoreError.encodingFailed
}
return string
}
}
extension Array: StoreConvertible where Element: Codable {
typealias StorageType = String
}
extension Dictionary: StoreConvertible where Key == String, Value: Codable {
typealias StorageType = String
}
class StoreRepository {
private let db: DatabasePool
init(db: DatabasePool) {
self.db = db
}
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) throws -> T? where T.StorageType == Int {
let query = Store.select(\.intValue).where { $0.id.eq(key.rawValue) }
if let value = try db.read({ conn in try query.fetchOne(conn) }) ?? nil {
return try T.fromValue(value)
}
return nil
}
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) throws -> T? where T.StorageType == String {
let query = Store.select(\.stringValue).where { $0.id.eq(key.rawValue) }
if let value = try db.read({ conn in try query.fetchOne(conn) }) ?? nil {
return try T.fromValue(value)
}
return nil
}
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) async throws -> T? where T.StorageType == Int {
let query = Store.select(\.intValue).where { $0.id.eq(key.rawValue) }
if let value = try await db.read({ conn in try query.fetchOne(conn) }) ?? nil {
return try T.fromValue(value)
}
return nil
}
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) async throws -> T? where T.StorageType == String {
let query = Store.select(\.stringValue).where { $0.id.eq(key.rawValue) }
if let value = try await db.read({ conn in try query.fetchOne(conn) }) ?? nil {
return try T.fromValue(value)
}
return nil
}
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T) throws where T.StorageType == Int {
let value = try T.toValue(value)
try db.write { conn in
try Store.upsert { Store(id: key.rawValue, stringValue: nil, intValue: value) }.execute(conn)
}
}
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T) throws where T.StorageType == String {
let value = try T.toValue(value)
try db.write { conn in
try Store.upsert { Store(id: key.rawValue, stringValue: value, intValue: nil) }.execute(conn)
}
}
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T) async throws where T.StorageType == Int {
let value = try T.toValue(value)
try await db.write { conn in
try Store.upsert { Store(id: key.rawValue, stringValue: nil, intValue: value) }.execute(conn)
}
}
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T) async throws where T.StorageType == String {
let value = try T.toValue(value)
try await db.write { conn in
try Store.upsert { Store(id: key.rawValue, stringValue: value, intValue: nil) }.execute(conn)
}
}
}
+237
View File
@@ -0,0 +1,237 @@
import GRDB
import SQLiteData
@Table("asset_face_entity")
struct AssetFace {
let id: String
let assetId: String
let personId: String?
let imageWidth: Int
let imageHeight: Int
let boundingBoxX1: Int
let boundingBoxY1: Int
let boundingBoxX2: Int
let boundingBoxY2: Int
let sourceType: String
}
@Table("auth_user_entity")
struct AuthUser {
let id: String
let name: String
let email: String
let isAdmin: Bool
let hasProfileImage: Bool
let profileChangedAt: Date
let avatarColor: AvatarColor
let quotaSizeInBytes: Int
let quotaUsageInBytes: Int
let pinCode: String?
}
@Table("local_album_entity")
struct LocalAlbum {
let id: String
let backupSelection: BackupSelection
let linkedRemoteAlbumId: String?
let marker_: Bool?
let name: String
let isIosSharedAlbum: Bool
let updatedAt: Date
}
@Table("local_album_asset_entity")
struct LocalAlbumAsset {
let id: ID
let marker_: String?
@Selection
struct ID {
let assetId: String
let albumId: String
}
}
@Table("local_asset_entity")
struct LocalAsset {
let id: String
let checksum: String?
let createdAt: Date
let durationInSeconds: Int?
let height: Int?
let isFavorite: Bool
let name: String
let orientation: String
let type: Int
let updatedAt: Date
let width: Int?
}
@Table("memory_asset_entity")
struct MemoryAsset {
let id: ID
@Selection
struct ID {
let assetId: String
let albumId: String
}
}
@Table("memory_entity")
struct Memory {
let id: String
let createdAt: Date
let updatedAt: Date
let deletedAt: Date?
let ownerId: String
let type: MemoryType
let data: String
let isSaved: Bool
let memoryAt: Date
let seenAt: Date?
let showAt: Date?
let hideAt: Date?
}
@Table("partner_entity")
struct Partner {
let id: ID
let inTimeline: Bool
@Selection
struct ID {
let sharedById: String
let sharedWithId: String
}
}
@Table("person_entity")
struct Person {
let id: String
let createdAt: Date
let updatedAt: Date
let ownerId: String
let name: String
let faceAssetId: String?
let isFavorite: Bool
let isHidden: Bool
let color: String?
let birthDate: Date?
}
@Table("remote_album_entity")
struct RemoteAlbum {
let id: String
let createdAt: Date
let description: String?
let isActivityEnabled: Bool
let name: String
let order: Int
let ownerId: String
let thumbnailAssetId: String?
let updatedAt: Date
}
@Table("remote_album_asset_entity")
struct RemoteAlbumAsset {
let id: ID
@Selection
struct ID {
let assetId: String
let albumId: String
}
}
@Table("remote_album_user_entity")
struct RemoteAlbumUser {
let id: ID
let role: AlbumUserRole
@Selection
struct ID {
let albumId: String
let userId: String
}
}
@Table("remote_asset_entity")
struct RemoteAsset {
let id: String
let checksum: String?
let deletedAt: Date?
let isFavorite: Int
let libraryId: String?
let livePhotoVideoId: String?
let localDateTime: Date?
let orientation: String
let ownerId: String
let stackId: String?
let visibility: Int
}
@Table("remote_exif_entity")
struct RemoteExif {
@Column(primaryKey: true)
let assetId: String
let city: String?
let state: String?
let country: String?
let dateTimeOriginal: Date?
let description: String?
let height: Int?
let width: Int?
let exposureTime: String?
let fNumber: Double?
let fileSize: Int?
let focalLength: Double?
let latitude: Double?
let longitude: Double?
let iso: Int?
let make: String?
let model: String?
let lens: String?
let orientation: String?
let timeZone: String?
let rating: Int?
let projectionType: String?
}
@Table("stack_entity")
struct Stack {
let id: String
let createdAt: Date
let updatedAt: Date
let ownerId: String
let primaryAssetId: String
}
@Table("store_entity")
struct Store {
let id: StoreKey
let stringValue: String?
let intValue: Int?
}
@Table("user_entity")
struct User {
let id: String
let name: String
let email: String
let hasProfileImage: Bool
let profileChangedAt: Date
let avatarColor: AvatarColor
}
@Table("user_metadata_entity")
struct UserMetadata {
let id: ID
let value: Data
@Selection
struct ID {
let userId: String
let key: Date
}
}
+115 -9
View File
@@ -128,6 +128,15 @@ func deepHashMessages(value: Any?, hasher: inout Hasher) {
enum PlatformAssetPlaybackStyle: Int {
case unknown = 0
case image = 1
case video = 2
case imageAnimated = 3
case livePhoto = 4
case videoLooping = 5
}
/// Generated class from Pigeon that represents data sent in messages.
struct PlatformAsset: Hashable {
var id: String
@@ -140,6 +149,10 @@ struct PlatformAsset: Hashable {
var durationInSeconds: Int64
var orientation: Int64
var isFavorite: Bool
var adjustmentTime: Int64? = nil
var latitude: Double? = nil
var longitude: Double? = nil
var playbackStyle: PlatformAssetPlaybackStyle
// swift-format-ignore: AlwaysUseLowerCamelCase
@@ -154,6 +167,10 @@ struct PlatformAsset: Hashable {
let durationInSeconds = pigeonVar_list[7] as! Int64
let orientation = pigeonVar_list[8] as! Int64
let isFavorite = pigeonVar_list[9] as! Bool
let adjustmentTime: Int64? = nilOrValue(pigeonVar_list[10])
let latitude: Double? = nilOrValue(pigeonVar_list[11])
let longitude: Double? = nilOrValue(pigeonVar_list[12])
let playbackStyle = pigeonVar_list[13] as! PlatformAssetPlaybackStyle
return PlatformAsset(
id: id,
@@ -165,7 +182,11 @@ struct PlatformAsset: Hashable {
height: height,
durationInSeconds: durationInSeconds,
orientation: orientation,
isFavorite: isFavorite
isFavorite: isFavorite,
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
playbackStyle: playbackStyle
)
}
func toList() -> [Any?] {
@@ -180,6 +201,10 @@ struct PlatformAsset: Hashable {
durationInSeconds,
orientation,
isFavorite,
adjustmentTime,
latitude,
longitude,
playbackStyle,
]
}
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {
@@ -300,17 +325,58 @@ struct HashResult: Hashable {
}
}
/// Generated class from Pigeon that represents data sent in messages.
struct CloudIdResult: Hashable {
var assetId: String
var error: String? = nil
var cloudId: String? = nil
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> CloudIdResult? {
let assetId = pigeonVar_list[0] as! String
let error: String? = nilOrValue(pigeonVar_list[1])
let cloudId: String? = nilOrValue(pigeonVar_list[2])
return CloudIdResult(
assetId: assetId,
error: error,
cloudId: cloudId
)
}
func toList() -> [Any?] {
return [
assetId,
error,
cloudId,
]
}
static func == (lhs: CloudIdResult, rhs: CloudIdResult) -> 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 {
case 129:
return PlatformAsset.fromList(self.readValue() as! [Any?])
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
if let enumResultAsInt = enumResultAsInt {
return PlatformAssetPlaybackStyle(rawValue: enumResultAsInt)
}
return nil
case 130:
return PlatformAlbum.fromList(self.readValue() as! [Any?])
return PlatformAsset.fromList(self.readValue() as! [Any?])
case 131:
return SyncDelta.fromList(self.readValue() as! [Any?])
return PlatformAlbum.fromList(self.readValue() as! [Any?])
case 132:
return SyncDelta.fromList(self.readValue() as! [Any?])
case 133:
return HashResult.fromList(self.readValue() as! [Any?])
case 134:
return CloudIdResult.fromList(self.readValue() as! [Any?])
default:
return super.readValue(ofType: type)
}
@@ -319,18 +385,24 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
private class MessagesPigeonCodecWriter: FlutterStandardWriter {
override func writeValue(_ value: Any) {
if let value = value as? PlatformAsset {
if let value = value as? PlatformAssetPlaybackStyle {
super.writeByte(129)
super.writeValue(value.toList())
} else if let value = value as? PlatformAlbum {
super.writeValue(value.rawValue)
} else if let value = value as? PlatformAsset {
super.writeByte(130)
super.writeValue(value.toList())
} else if let value = value as? SyncDelta {
} else if let value = value as? PlatformAlbum {
super.writeByte(131)
super.writeValue(value.toList())
} else if let value = value as? HashResult {
} else if let value = value as? SyncDelta {
super.writeByte(132)
super.writeValue(value.toList())
} else if let value = value as? HashResult {
super.writeByte(133)
super.writeValue(value.toList())
} else if let value = value as? CloudIdResult {
super.writeByte(134)
super.writeValue(value.toList())
} else {
super.writeValue(value)
}
@@ -364,6 +436,8 @@ protocol NativeSyncApi {
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
func cancelHashing() throws
func getTrashedAssets() throws -> [String: [PlatformAsset]]
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -532,5 +606,37 @@ class NativeSyncApiSetup {
} else {
cancelHashingChannel.setMessageHandler(nil)
}
let getTrashedAssetsChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getTrashedAssetsChannel.setMessageHandler { _, reply in
do {
let result = try api.getTrashedAssets()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
getTrashedAssetsChannel.setMessageHandler(nil)
}
let getCloudIdForAssetIdsChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getCloudIdForAssetIdsChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let assetIdsArg = args[0] as! [String]
do {
let result = try api.getCloudIdForAssetIds(assetIds: assetIdsArg)
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
getCloudIdForAssetIdsChannel.setMessageHandler(nil)
}
}
}
+33 -4
View File
@@ -3,15 +3,15 @@ import CryptoKit
struct AssetWrapper: Hashable, Equatable {
let asset: PlatformAsset
init(with asset: PlatformAsset) {
self.asset = asset
}
func hash(into hasher: inout Hasher) {
hasher.combine(self.asset.id)
}
static func == (lhs: AssetWrapper, rhs: AssetWrapper) -> Bool {
return lhs.asset.id == rhs.asset.id
}
@@ -173,7 +173,8 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
type: 0,
durationInSeconds: 0,
orientation: 0,
isFavorite: false
isFavorite: false,
playbackStyle: .unknown
)
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
continue
@@ -378,6 +379,10 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
})
}
func getTrashedAssets() throws -> [String: [PlatformAsset]] {
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
}
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> {
// Ensure to actually getting all assets for the Recents album
if (album.assetCollectionSubtype == .smartAlbumUserLibrary) {
@@ -386,4 +391,28 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return PHAsset.fetchAssets(in: album, options: options)
}
}
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult] {
guard #available(iOS 16, *) else {
return assetIds.map { CloudIdResult(assetId: $0) }
}
var mappings: [CloudIdResult] = []
let result = PHPhotoLibrary.shared().cloudIdentifierMappings(forLocalIdentifiers: assetIds)
for (key, value) in result {
switch value {
case .success(let cloudIdentifier):
let cloudId = cloudIdentifier.stringValue
// Ignores invalid cloud ids of the format "GUID:ID:". Valid Ids are of the form "GUID:ID:HASH"
if !cloudId.hasSuffix(":") {
mappings.append(CloudIdResult(assetId: key, cloudId: cloudId))
} else {
mappings.append(CloudIdResult(assetId: key, error: "Incomplete Cloud Id: \(cloudId)"))
}
case .failure(let error):
mappings.append(CloudIdResult(assetId: key, error: "Error getting Cloud Id: \(error.localizedDescription)"))
}
}
return mappings;
}
}
+23 -1
View File
@@ -1,6 +1,17 @@
import Photos
extension PHAsset {
var platformPlaybackStyle: PlatformAssetPlaybackStyle {
switch playbackStyle {
case .image: return .image
case .imageAnimated: return .imageAnimated
case .livePhoto: return .livePhoto
case .video: return .video
case .videoLooping: return .videoLooping
@unknown default: return .unknown
}
}
func toPlatformAsset() -> PlatformAsset {
return PlatformAsset(
id: localIdentifier,
@@ -12,7 +23,11 @@ extension PHAsset {
height: Int64(pixelHeight),
durationInSeconds: Int64(duration),
orientation: 0,
isFavorite: isFavorite
isFavorite: isFavorite,
adjustmentTime: adjustmentTimestamp,
latitude: location?.coordinate.latitude,
longitude: location?.coordinate.longitude,
playbackStyle: platformPlaybackStyle
)
}
@@ -24,6 +39,13 @@ extension PHAsset {
return value(forKey: "filename") as? String
}
var adjustmentTimestamp: Int64? {
if let date = value(forKey: "adjustmentTimestamp") as? Date {
return Int64(date.timeIntervalSince1970)
}
return nil
}
// This method is expected to be slow as it goes through the asset resources to fetch the originalFilename
var originalFilename: String? {
return getResource()?.originalFilename
+1 -1
View File
@@ -225,7 +225,7 @@ class ImmichAPI {
}
func fetchImage(asset: Asset) async throws(FetchError) -> UIImage {
let thumbnailParams = [URLQueryItem(name: "size", value: "preview")]
let thumbnailParams = [URLQueryItem(name: "size", value: "preview"), URLQueryItem(name: "edited", value: "true")]
let assetEndpoint = "/assets/" + asset.id + "/thumbnail"
guard
+99 -22
View File
@@ -44,7 +44,7 @@ def get_version_from_pubspec
end
# Helper method to configure code signing for all targets
def configure_code_signing(bundle_id_suffix: "")
def configure_code_signing(bundle_id_suffix: "", profile_name_main:, profile_name_share:, profile_name_widget:)
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
# Runner (main app)
@@ -54,7 +54,7 @@ end
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}",
profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix} AppStore",
profile_name: profile_name_main,
targets: ["Runner"]
)
@@ -65,7 +65,7 @@ end
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension",
profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension AppStore",
profile_name: profile_name_share,
targets: ["ShareExtension"]
)
@@ -76,7 +76,7 @@ end
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget",
profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget AppStore",
profile_name: profile_name_widget,
targets: ["WidgetExtension"]
)
end
@@ -87,7 +87,10 @@ end
bundle_id_suffix: "",
configuration: "Release",
distribute_external: true,
version_number: nil
version_number: nil,
profile_name_main:,
profile_name_share:,
profile_name_widget:
)
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
app_identifier = "#{BASE_BUNDLE_ID}#{bundle_suffix}"
@@ -112,12 +115,12 @@ end
workspace: "Runner.xcworkspace",
configuration: configuration,
export_method: "app-store",
xcargs: "CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
export_options: {
provisioningProfiles: {
"#{app_identifier}" => "#{app_identifier} AppStore",
"#{app_identifier}.ShareExtension" => "#{app_identifier}.ShareExtension AppStore",
"#{app_identifier}.Widget" => "#{app_identifier}.Widget AppStore"
"#{app_identifier}" => profile_name_main,
"#{app_identifier}.ShareExtension" => profile_name_share,
"#{app_identifier}.Widget" => profile_name_widget
},
signingStyle: "manual",
signingCertificate: CODE_SIGN_IDENTITY
@@ -136,20 +139,35 @@ end
lane :gha_testflight_dev do
api_key = get_api_key
# Install development provisioning profiles
install_provisioning_profile(path: "profile_dev.mobileprovision")
install_provisioning_profile(path: "profile_dev_share.mobileprovision")
install_provisioning_profile(path: "profile_dev_widget.mobileprovision")
# Download and install provisioning profiles from App Store Connect
# Certificate is imported by GHA workflow into build.keychain
# Capture profile names after each sigh call
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true)
main_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for dev bundle IDs
configure_code_signing(bundle_id_suffix: "development")
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true)
share_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true)
widget_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for dev bundle IDs using the downloaded profile names
configure_code_signing(
bundle_id_suffix: "development",
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
)
# Build and upload
build_and_upload(
api_key: api_key,
bundle_id_suffix: "development",
configuration: "Profile",
distribute_external: false
distribute_external: false,
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
)
end
@@ -157,20 +175,33 @@ end
lane :gha_release_prod do
api_key = get_api_key
# Install provisioning profiles
install_provisioning_profile(path: "profile.mobileprovision")
install_provisioning_profile(path: "profile_share.mobileprovision")
install_provisioning_profile(path: "profile_widget.mobileprovision")
# Download and install provisioning profiles from App Store Connect
# Certificate is imported by GHA workflow into build.keychain
sigh(api_key: api_key, app_identifier: BASE_BUNDLE_ID, force: true)
main_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.ShareExtension", force: true)
share_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.Widget", force: true)
widget_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for production bundle IDs
configure_code_signing
configure_code_signing(
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
)
# Build and upload with version number
build_and_upload(
api_key: api_key,
version_number: get_version_from_pubspec,
distribute_external: false,
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
)
end
@@ -195,7 +226,7 @@ end
configuration: "Release",
export_method: "app-store",
skip_package_ipa: false,
xcargs: "-allowProvisioningUpdates",
xcargs: "-skipMacroValidation -allowProvisioningUpdates",
export_options: {
method: "app-store",
signingStyle: "automatic",
@@ -210,4 +241,50 @@ end
)
end
desc "iOS Build Only (no TestFlight upload)"
lane :gha_build_only do
# Use the same build process as production, just skip the upload
# This ensures PR builds validate the same way as production builds
api_key = get_api_key
# Download and install provisioning profiles from App Store Connect
# Certificate is imported by GHA workflow into build.keychain
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true)
main_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true)
share_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true)
widget_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for dev bundle IDs
configure_code_signing(
bundle_id_suffix: "development",
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
)
# Build the app (same as gha_testflight_dev but without upload)
build_app(
scheme: "Runner",
workspace: "Runner.xcworkspace",
configuration: "Release",
export_method: "app-store",
skip_package_ipa: true,
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
export_options: {
provisioningProfiles: {
"#{BASE_BUNDLE_ID}.development" => main_profile_name,
"#{BASE_BUNDLE_ID}.development.ShareExtension" => share_profile_name,
"#{BASE_BUNDLE_ID}.development.Widget" => widget_profile_name
},
signingStyle: "manual",
signingCertificate: CODE_SIGN_IDENTITY
}
)
end
end
+8
View File
@@ -39,6 +39,14 @@ iOS Release to TestFlight
iOS Manual Release
### ios gha_build_only
```sh
[bundle exec] fastlane ios gha_build_only
```
iOS Build Only (no TestFlight upload)
----
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
+1 -1
View File
@@ -7,6 +7,6 @@ const String defaultColorPresetName = "indigo";
const Color immichBrandColorLight = Color(0xFF4150AF);
const Color immichBrandColorDark = Color(0xFFACCBFA);
const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255);
const Color whiteOpacity75 = Color.fromRGBO(255, 255, 255, 0.75);
const Color red400 = Color(0xFFEF5350);
const Color grey200 = Color(0xFFEEEEEE);
+6 -1
View File
@@ -4,6 +4,8 @@ const int noDbId = -9223372036854775808; // from Isar
const double downloadCompleted = -1;
const double downloadFailed = -2;
const String kMobileMetadataKey = "mobile-app";
// Number of log entries to retain on app start
const int kLogTruncateLimit = 2000;
@@ -50,7 +52,7 @@ const double kUploadStatusCanceled = -2.0;
const int kMinMonthsToEnableScrubberSnap = 12;
const String kImmichAppStoreLink = "https://apps.apple.com/app/immich/id6449244941";
const String kImmichAppStoreLink = "https://apps.apple.com/app/immich/id1613945652";
const String kImmichPlayStoreLink = "https://play.google.com/store/apps/details?id=app.alextran.immich";
const String kImmichLatestRelease = "https://github.com/immich-app/immich/releases/latest";
@@ -58,3 +60,6 @@ const int kPhotoTabIndex = 0;
const int kSearchTabIndex = 1;
const int kAlbumTabIndex = 2;
const int kLibraryTabIndex = 3;
// Workaround for SQLite's variable limit (SQLITE_MAX_VARIABLE_NUMBER = 32766)
const int kDriftMaxChunk = 32000;
+14 -1
View File
@@ -1,4 +1,11 @@
enum SortOrder { asc, desc }
enum SortOrder {
asc,
desc;
SortOrder reverse() {
return this == SortOrder.asc ? SortOrder.desc : SortOrder.asc;
}
}
enum TextSearchType { context, filename, description, ocr }
@@ -7,3 +14,9 @@ enum AssetVisibilityEnum { timeline, hidden, archive, locked }
enum SortUserBy { id }
enum ActionSource { timeline, viewer }
enum CleanupStep { selectDate, scan, delete }
enum AssetKeepType { none, photosOnly, videosOnly }
enum AssetDateAggregation { start, end }
+2 -2
View File
@@ -7,7 +7,7 @@ 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 Simplified (zh_CN)': Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'),
'Chinese Traditional (zh_TW)': Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'),
'Croatian (hr)': Locale('hr'),
'Czech (cs)': Locale('cs'),
@@ -51,4 +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> localesNotSupportedByAppFont = [Locale('el', 'GR'), Locale('sr', 'Cyrl')];
@@ -0,0 +1,62 @@
enum RemoteAssetMetadataKey {
mobileApp("mobile-app");
final String key;
const RemoteAssetMetadataKey(this.key);
}
abstract class RemoteAssetMetadataValue {
const RemoteAssetMetadataValue();
Map<String, dynamic> toJson();
}
class RemoteAssetMetadataItem {
final RemoteAssetMetadataKey key;
final RemoteAssetMetadataValue value;
const RemoteAssetMetadataItem({required this.key, required this.value});
Map<String, Object?> toJson() {
return {'key': key.key, 'value': value};
}
}
class RemoteAssetMobileAppMetadata extends RemoteAssetMetadataValue {
final String? cloudId;
final String? createdAt;
final String? adjustmentTime;
final String? latitude;
final String? longitude;
const RemoteAssetMobileAppMetadata({
this.cloudId,
this.createdAt,
this.adjustmentTime,
this.latitude,
this.longitude,
});
@override
Map<String, dynamic> toJson() {
final map = <String, Object?>{};
if (cloudId != null) {
map["iCloudId"] = cloudId;
}
if (createdAt != null) {
map["createdAt"] = createdAt;
}
if (adjustmentTime != null) {
map["adjustmentTime"] = adjustmentTime;
}
if (latitude != null) {
map["latitude"] = latitude;
}
if (longitude != null) {
map["longitude"] = longitude;
}
return map;
}
}
@@ -11,6 +11,10 @@ enum AssetType {
enum AssetState { local, remote, merged }
// do not change!
// keep in sync with PlatformAssetPlaybackStyle
enum AssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
sealed class BaseAsset {
final String name;
final String? checksum;
@@ -22,6 +26,7 @@ sealed class BaseAsset {
final int? durationInSeconds;
final bool isFavorite;
final String? livePhotoVideoId;
final bool isEdited;
const BaseAsset({
required this.name,
@@ -34,12 +39,22 @@ sealed class BaseAsset {
this.durationInSeconds,
this.isFavorite = false,
this.livePhotoVideoId,
required this.isEdited,
});
bool get isImage => type == AssetType.image;
bool get isVideo => type == AssetType.video;
bool get isMotionPhoto => livePhotoVideoId != null;
bool get isAnimatedImage => playbackStyle == AssetPlaybackStyle.imageAnimated;
AssetPlaybackStyle get playbackStyle {
if (isVideo) return AssetPlaybackStyle.video;
if (isMotionPhoto) return AssetPlaybackStyle.livePhoto;
if (isImage && durationInSeconds != null && durationInSeconds! > 0) return AssetPlaybackStyle.imageAnimated;
if (isImage) return AssetPlaybackStyle.image;
return AssetPlaybackStyle.unknown;
}
Duration get duration {
final durationInSeconds = this.durationInSeconds;
@@ -71,6 +86,7 @@ sealed class BaseAsset {
height: ${height ?? "<NA>"},
durationInSeconds: ${durationInSeconds ?? "<NA>"},
isFavorite: $isFavorite,
isEdited: $isEdited,
}''';
}
@@ -85,7 +101,8 @@ sealed class BaseAsset {
width == other.width &&
height == other.height &&
durationInSeconds == other.durationInSeconds &&
isFavorite == other.isFavorite;
isFavorite == other.isFavorite &&
isEdited == other.isEdited;
}
return false;
}
@@ -99,6 +116,7 @@ sealed class BaseAsset {
width.hashCode ^
height.hashCode ^
durationInSeconds.hashCode ^
isFavorite.hashCode;
isFavorite.hashCode ^
isEdited.hashCode;
}
}
@@ -3,11 +3,19 @@ part of 'base_asset.model.dart';
class LocalAsset extends BaseAsset {
final String id;
final String? remoteAssetId;
final String? cloudId;
final int orientation;
@override
final AssetPlaybackStyle playbackStyle;
final DateTime? adjustmentTime;
final double? latitude;
final double? longitude;
const LocalAsset({
required this.id,
String? remoteId,
this.cloudId,
required super.name,
super.checksum,
required super.type,
@@ -19,6 +27,11 @@ class LocalAsset extends BaseAsset {
super.isFavorite = false,
super.livePhotoVideoId,
this.orientation = 0,
required this.playbackStyle,
this.adjustmentTime,
this.latitude,
this.longitude,
required super.isEdited,
}) : remoteAssetId = remoteId;
@override
@@ -33,6 +46,8 @@ class LocalAsset extends BaseAsset {
@override
String get heroTag => '${id}_${remoteId ?? checksum}';
bool get hasCoordinates => latitude != null && longitude != null && latitude != 0 && longitude != 0;
@override
String toString() {
return '''LocalAsset {
@@ -44,9 +59,15 @@ class LocalAsset extends BaseAsset {
width: ${width ?? "<NA>"},
height: ${height ?? "<NA>"},
durationInSeconds: ${durationInSeconds ?? "<NA>"},
remoteId: ${remoteId ?? "<NA>"}
playbackStyle: $playbackStyle,
remoteId: ${remoteId ?? "<NA>"},
cloudId: ${cloudId ?? "<NA>"},
checksum: ${checksum ?? "<NA>"},
isFavorite: $isFavorite,
orientation: $orientation,
orientation: $orientation,
adjustmentTime: $adjustmentTime,
latitude: ${latitude ?? "<NA>"},
longitude: ${longitude ?? "<NA>"},
}''';
}
@@ -55,15 +76,31 @@ class LocalAsset extends BaseAsset {
bool operator ==(Object other) {
if (other is! LocalAsset) return false;
if (identical(this, other)) return true;
return super == other && id == other.id && orientation == other.orientation;
return super == other &&
id == other.id &&
cloudId == other.cloudId &&
orientation == other.orientation &&
playbackStyle == other.playbackStyle &&
adjustmentTime == other.adjustmentTime &&
latitude == other.latitude &&
longitude == other.longitude;
}
@override
int get hashCode => super.hashCode ^ id.hashCode ^ remoteId.hashCode ^ orientation.hashCode;
int get hashCode =>
super.hashCode ^
id.hashCode ^
remoteId.hashCode ^
orientation.hashCode ^
playbackStyle.hashCode ^
adjustmentTime.hashCode ^
latitude.hashCode ^
longitude.hashCode;
LocalAsset copyWith({
String? id,
String? remoteId,
String? cloudId,
String? name,
String? checksum,
AssetType? type,
@@ -74,10 +111,16 @@ class LocalAsset extends BaseAsset {
int? durationInSeconds,
bool? isFavorite,
int? orientation,
AssetPlaybackStyle? playbackStyle,
DateTime? adjustmentTime,
double? latitude,
double? longitude,
bool? isEdited,
}) {
return LocalAsset(
id: id ?? this.id,
remoteId: remoteId ?? this.remoteId,
cloudId: cloudId ?? this.cloudId,
name: name ?? this.name,
checksum: checksum ?? this.checksum,
type: type ?? this.type,
@@ -88,6 +131,11 @@ class LocalAsset extends BaseAsset {
durationInSeconds: durationInSeconds ?? this.durationInSeconds,
isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation,
playbackStyle: playbackStyle ?? this.playbackStyle,
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
isEdited: isEdited ?? this.isEdited,
);
}
}
@@ -28,6 +28,7 @@ class RemoteAsset extends BaseAsset {
this.visibility = AssetVisibility.timeline,
super.livePhotoVideoId,
this.stackId,
required super.isEdited,
}) : localAssetId = localId;
@override
@@ -104,6 +105,7 @@ class RemoteAsset extends BaseAsset {
AssetVisibility? visibility,
String? livePhotoVideoId,
String? stackId,
bool? isEdited,
}) {
return RemoteAsset(
id: id ?? this.id,
@@ -122,6 +124,7 @@ class RemoteAsset extends BaseAsset {
visibility: visibility ?? this.visibility,
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
stackId: stackId ?? this.stackId,
isEdited: isEdited ?? this.isEdited,
);
}
}
@@ -0,0 +1,21 @@
import "package:openapi/api.dart" as api show AssetEditAction;
enum AssetEditAction { rotate, crop, mirror, other }
extension AssetEditActionExtension on AssetEditAction {
api.AssetEditAction? toDto() {
return switch (this) {
AssetEditAction.rotate => api.AssetEditAction.rotate,
AssetEditAction.crop => api.AssetEditAction.crop,
AssetEditAction.mirror => api.AssetEditAction.mirror,
AssetEditAction.other => null,
};
}
}
class AssetEdit {
final AssetEditAction action;
final Map<String, dynamic> parameters;
const AssetEdit({required this.action, required this.parameters});
}
@@ -0,0 +1,36 @@
import 'package:immich_mobile/domain/utils/event_stream.dart';
// Timeline Events
class TimelineReloadEvent extends Event {
const TimelineReloadEvent();
}
class ScrollToTopEvent extends Event {
const ScrollToTopEvent();
}
class ScrollToDateEvent extends Event {
final DateTime date;
const ScrollToDateEvent(this.date);
}
// Asset Viewer Events
class ViewerShowDetailsEvent extends Event {
const ViewerShowDetailsEvent();
}
class ViewerReloadAssetEvent extends Event {
const ViewerReloadAssetEvent();
}
// Multi-Select Events
class MultiSelectToggleEvent extends Event {
final bool isEnabled;
const MultiSelectToggleEvent(this.isEnabled);
}
// Map Events
class MapMarkerReloadEvent extends Event {
const MapMarkerReloadEvent();
}
+8 -11
View File
@@ -3,11 +3,10 @@ class ExifInfo {
final int? fileSize;
final String? description;
final bool isFlipped;
final double? width;
final double? height;
final String? orientation;
final String? timeZone;
final DateTime? dateTimeOriginal;
final int? rating;
// GPS
final double? latitude;
@@ -39,17 +38,16 @@ class ExifInfo {
String get fNumber => f == null ? "" : f!.toStringAsFixed(1);
String get focalLength => mm == null ? "" : mm!.toStringAsFixed(1);
String get focalLength => mm == null ? "" : mm!.toStringAsFixed(3);
const ExifInfo({
this.assetId,
this.fileSize,
this.description,
this.orientation,
this.width,
this.height,
this.timeZone,
this.dateTimeOriginal,
this.rating,
this.isFlipped = false,
this.latitude,
this.longitude,
@@ -72,11 +70,10 @@ class ExifInfo {
return other.fileSize == fileSize &&
other.description == description &&
other.isFlipped == isFlipped &&
other.width == width &&
other.height == height &&
other.orientation == orientation &&
other.timeZone == timeZone &&
other.dateTimeOriginal == dateTimeOriginal &&
other.rating == rating &&
other.latitude == latitude &&
other.longitude == longitude &&
other.city == city &&
@@ -98,10 +95,9 @@ class ExifInfo {
description.hashCode ^
orientation.hashCode ^
isFlipped.hashCode ^
width.hashCode ^
height.hashCode ^
timeZone.hashCode ^
dateTimeOriginal.hashCode ^
rating.hashCode ^
latitude.hashCode ^
longitude.hashCode ^
city.hashCode ^
@@ -123,11 +119,10 @@ class ExifInfo {
fileSize: ${fileSize ?? 'NA'},
description: ${description ?? 'NA'},
orientation: ${orientation ?? 'NA'},
width: ${width ?? 'NA'},
height: ${height ?? 'NA'},
isFlipped: $isFlipped,
timeZone: ${timeZone ?? 'NA'},
dateTimeOriginal: ${dateTimeOriginal ?? 'NA'},
rating: ${rating ?? 'NA'},
latitude: ${latitude ?? 'NA'},
longitude: ${longitude ?? 'NA'},
city: ${city ?? 'NA'},
@@ -150,6 +145,7 @@ exposureSeconds: ${exposureSeconds ?? 'NA'},
String? orientation,
String? timeZone,
DateTime? dateTimeOriginal,
int? rating,
double? latitude,
double? longitude,
String? city,
@@ -171,6 +167,7 @@ exposureSeconds: ${exposureSeconds ?? 'NA'},
orientation: orientation ?? this.orientation,
timeZone: timeZone ?? this.timeZone,
dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal,
rating: rating ?? this.rating,
isFlipped: isFlipped ?? this.isFlipped,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
@@ -3,30 +3,21 @@ 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.scrollOffset = 0.0, this.nextPage});
SearchResult copyWith({List<BaseAsset>? assets, int? nextPage, double? scrollOffset}) {
return SearchResult(
assets: assets ?? this.assets,
nextPage: nextPage ?? this.nextPage,
scrollOffset: scrollOffset ?? this.scrollOffset,
);
}
const SearchResult({required this.assets, this.nextPage});
@override
String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage, scrollOffset: $scrollOffset)';
String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage)';
@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 && other.scrollOffset == scrollOffset;
return listEquals(other.assets, assets) && other.nextPage == nextPage;
}
@override
int get hashCode => assets.hashCode ^ nextPage.hashCode ^ scrollOffset.hashCode;
int get hashCode => assets.hashCode ^ nextPage.hashCode;
}
+14 -1
View File
@@ -71,6 +71,10 @@ enum StoreKey<T> {
readonlyModeEnabled<bool>._(138),
autoPlayVideo<bool>._(139),
albumGridView<bool>._(140),
// Image viewer navigation settings
tapToNavigate<bool>._(141),
// Experimental stuff
photoManagerCustomFilter<bool>._(1000),
@@ -81,7 +85,16 @@ enum StoreKey<T> {
useWifiForUploadPhotos<bool>._(1005),
needBetaMigration<bool>._(1006),
// TODO: Remove this after patching open-api
shouldResetSync<bool>._(1007);
shouldResetSync<bool>._(1007),
// Free up space
cleanupKeepFavorites<bool>._(1008),
cleanupKeepMediaType<int>._(1009),
cleanupKeepAlbumIds<String>._(1010),
cleanupCutoffDaysAgo<int>._(1011),
cleanupDefaultsInitialized<bool>._(1012),
syncMigrationStatus<String>._(1013);
const StoreKey._(this.id);
final int id;
+29
View File
@@ -0,0 +1,29 @@
import 'package:openapi/api.dart';
class Tag {
final String id;
final String value;
const Tag({required this.id, required this.value});
@override
String toString() {
return 'Tag(id: $id, value: $value)';
}
@override
bool operator ==(covariant Tag other) {
if (identical(this, other)) return true;
return other.id == id && other.value == value;
}
@override
int get hashCode {
return id.hashCode ^ value.hashCode;
}
static Tag fromDto(TagResponseDto dto) {
return Tag(id: dto.id, value: dto.value);
}
}
@@ -1,5 +1,3 @@
import 'package:immich_mobile/domain/utils/event_stream.dart';
enum GroupAssetsBy { day, month, auto, none }
enum HeaderType { none, month, day, monthAndDay }
@@ -31,17 +29,3 @@ class TimeBucket extends Bucket {
@override
int get hashCode => super.hashCode ^ date.hashCode;
}
class TimelineReloadEvent extends Event {
const TimelineReloadEvent();
}
class ScrollToTopEvent extends Event {
const ScrollToTopEvent();
}
class ScrollToDateEvent extends Event {
final DateTime date;
const ScrollToDateEvent(this.date);
}
+37 -20
View File
@@ -4,7 +4,8 @@ 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';
typedef _AssetVideoDimension = ({double? width, double? height, bool isFlipped});
class AssetService {
final RemoteAssetRepository _remoteAssetRepository;
@@ -58,30 +59,46 @@ class AssetService {
}
Future<double> getAspectRatio(BaseAsset asset) async {
bool isFlipped;
double? width;
double? height;
final dimension = asset is LocalAsset
? await _getLocalAssetDimensions(asset)
: await _getRemoteAssetDimensions(asset as RemoteAsset);
if (asset.hasRemote) {
final exif = await getExif(asset);
isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
width = exif?.width ?? asset.width?.toDouble();
height = exif?.height ?? asset.height?.toDouble();
} else if (asset is LocalAsset) {
isFlipped = CurrentPlatform.isAndroid && (asset.orientation == 90 || asset.orientation == 270);
width = asset.width?.toDouble();
height = asset.height?.toDouble();
} else {
isFlipped = false;
if (dimension.width == null || dimension.height == null || dimension.height == 0) {
return 1.0;
}
final orientedWidth = isFlipped ? height : width;
final orientedHeight = isFlipped ? width : height;
if (orientedWidth != null && orientedHeight != null && orientedHeight > 0) {
return orientedWidth / orientedHeight;
return dimension.isFlipped ? dimension.height! / dimension.width! : dimension.width! / dimension.height!;
}
Future<_AssetVideoDimension> _getLocalAssetDimensions(LocalAsset asset) async {
double? width = asset.width?.toDouble();
double? height = asset.height?.toDouble();
int orientation = asset.orientation;
if (width == null || height == null) {
final fetched = await _localAssetRepository.get(asset.id);
width = fetched?.width?.toDouble();
height = fetched?.height?.toDouble();
orientation = fetched?.orientation ?? 0;
}
return 1.0;
// On Android, local assets need orientation correction for 90°/270° rotations
// On iOS, the Photos framework pre-corrects dimensions
final isFlipped = CurrentPlatform.isAndroid && (orientation == 90 || orientation == 270);
return (width: width, height: height, isFlipped: isFlipped);
}
Future<_AssetVideoDimension> _getRemoteAssetDimensions(RemoteAsset asset) async {
double? width = asset.width?.toDouble();
double? height = asset.height?.toDouble();
if (width == null || height == null) {
final fetched = await _remoteAssetRepository.get(asset.id);
width = fetched?.width?.toDouble();
height = fetched?.height?.toDouble();
}
return (width: width, height: height, isFlipped: false);
}
Future<List<(String, String)>> getPlaces(String userId) {
@@ -3,13 +3,11 @@ 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';
@@ -20,16 +18,15 @@ 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/infrastructure/platform.provider.dart' show nativeSyncApiProvider;
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/services/foreground_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:immich_mobile/wm_executor.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
@@ -65,7 +62,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
final Drift _drift;
final DriftLogger _driftLogger;
final BackgroundWorkerBgHostApi _backgroundHostApi;
final CancellationToken _cancellationToken = CancellationToken();
final _cancellationToken = Completer<void>();
final Logger _logger = Logger('BackgroundWorkerBgService');
bool _isCleanedUp = false;
@@ -89,8 +86,6 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
Future<void> init() async {
try {
HttpSSLOptions.apply(applyNative: false);
await Future.wait(
[
loadTranslations(),
@@ -177,6 +172,12 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
}
Future<void> _cleanup() async {
await runZonedGuarded(_handleCleanup, (error, stack) {
dPrint(() => "Error during background worker cleanup: $error, $stack");
});
}
Future<void> _handleCleanup() async {
// If ref is null, it means the service was never initialized properly
if (_isCleanedUp || _ref == null) {
return;
@@ -186,11 +187,16 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
_isCleanedUp = true;
final backgroundSyncManager = _ref?.read(backgroundSyncProvider);
final nativeSyncApi = _ref?.read(nativeSyncApiProvider);
await _drift.close();
await _driftLogger.close();
_ref?.dispose();
_ref = null;
_cancellationToken.cancel();
_cancellationToken.complete();
_logger.info("Cleaning up background worker");
final cleanupFutures = [
nativeSyncApi?.cancelHashing(),
workerManagerPatch.dispose().catchError((_) async {
@@ -199,8 +205,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
}),
LogService.I.dispose(),
Store.dispose(),
_drift.close(),
_driftLogger.close(),
backgroundSyncManager?.cancel(),
];
@@ -233,13 +238,12 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
}
if (Platform.isIOS) {
return _ref?.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id);
return _ref?.read(driftBackupProvider.notifier).startBackupWithURLSession(currentUser.id);
}
final networkCapabilities = await _ref?.read(connectivityApiProvider).getCapabilities() ?? [];
return _ref
?.read(uploadServiceProvider)
.startBackupWithHttpClient(currentUser.id, networkCapabilities.isUnmetered, _cancellationToken);
?.read(foregroundUploadServiceProvider)
.uploadCandidates(currentUser.id, _cancellationToken, useSequentialUpload: true);
},
(error, stack) {
dPrint(() => "Error in backup zone $error, $stack");
+25 -6
View File
@@ -2,8 +2,10 @@ 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/extensions/platform_extensions.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/trashed_local_asset.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:logging/logging.dart';
@@ -13,6 +15,7 @@ class HashService {
final int _batchSize;
final DriftLocalAlbumRepository _localAlbumRepository;
final DriftLocalAssetRepository _localAssetRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final NativeSyncApi _nativeSyncApi;
final bool Function()? _cancelChecker;
final _log = Logger('HashService');
@@ -20,11 +23,13 @@ class HashService {
HashService({
required DriftLocalAlbumRepository localAlbumRepository,
required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required NativeSyncApi nativeSyncApi,
bool Function()? cancelChecker,
int? batchSize,
}) : _localAlbumRepository = localAlbumRepository,
_localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository,
_cancelChecker = cancelChecker,
_nativeSyncApi = nativeSyncApi,
_batchSize = batchSize ?? kBatchHashFileLimit;
@@ -35,6 +40,9 @@ class HashService {
_log.info("Starting hashing of assets");
final Stopwatch stopwatch = Stopwatch()..start();
try {
// Migrate hashes from cloud ID to local ID so we don't have to re-hash them
// await _localAssetRepository.reconcileHashesFromCloudId();
// Sorted by backupSelection followed by isCloud
final localAlbums = await _localAlbumRepository.getBackupAlbums();
@@ -49,6 +57,14 @@ class HashService {
await _hashAssets(album, assetsToHash);
}
}
if (CurrentPlatform.isAndroid && localAlbums.isNotEmpty) {
final backupAlbumIds = localAlbums.map((e) => e.id);
final trashedToHash = await _trashedLocalAssetRepository.getAssetsToHash(backupAlbumIds);
if (trashedToHash.isNotEmpty) {
final pseudoAlbum = LocalAlbum(id: '-pseudoAlbum', name: 'Trash', updatedAt: DateTime.now());
await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true);
}
}
} on PlatformException catch (e) {
if (e.code == _kHashCancelledCode) {
_log.warning("Hashing cancelled by platform");
@@ -65,7 +81,7 @@ class HashService {
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
/// with hash for those that were successfully hashed. Hashes are looked up in a table
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash) async {
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash, {bool isTrashed = false}) async {
final toHash = <String, LocalAsset>{};
for (final asset in assetsToHash) {
@@ -76,16 +92,16 @@ class HashService {
toHash[asset.id] = asset;
if (toHash.length == _batchSize) {
await _processBatch(album, toHash);
await _processBatch(album, toHash, isTrashed);
toHash.clear();
}
}
await _processBatch(album, toHash);
await _processBatch(album, toHash, isTrashed);
}
/// Processes a batch of assets.
Future<void> _processBatch(LocalAlbum album, Map<String, LocalAsset> toHash) async {
Future<void> _processBatch(LocalAlbum album, Map<String, LocalAsset> toHash, bool isTrashed) async {
if (toHash.isEmpty) {
return;
}
@@ -120,7 +136,10 @@ class HashService {
}
_log.fine("Hashed ${hashed.length}/${toHash.length} assets");
await _localAssetRepository.updateHashes(hashed);
if (isTrashed) {
await _trashedLocalAssetRepository.updateHashes(hashed);
} else {
await _localAssetRepository.updateHashes(hashed);
}
}
}
@@ -4,25 +4,60 @@ import 'package:collection/collection.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/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.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/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:logging/logging.dart';
class LocalSyncService {
final DriftLocalAlbumRepository _localAlbumRepository;
// ignore: unused_field
final DriftLocalAssetRepository _localAssetRepository;
final NativeSyncApi _nativeSyncApi;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final LocalFilesManagerRepository _localFilesManager;
final StorageRepository _storageRepository;
final Logger _log = Logger("DeviceSyncService");
LocalSyncService({required DriftLocalAlbumRepository localAlbumRepository, required NativeSyncApi nativeSyncApi})
: _localAlbumRepository = localAlbumRepository,
_nativeSyncApi = nativeSyncApi;
LocalSyncService({
required DriftLocalAlbumRepository localAlbumRepository,
required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required LocalFilesManagerRepository localFilesManager,
required StorageRepository storageRepository,
required NativeSyncApi nativeSyncApi,
}) : _localAlbumRepository = localAlbumRepository,
_localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository,
_localFilesManager = localFilesManager,
_storageRepository = storageRepository,
_nativeSyncApi = nativeSyncApi;
Future<void> sync({bool full = false}) async {
final Stopwatch stopwatch = Stopwatch()..start();
try {
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
final hasPermission = await _localFilesManager.hasManageMediaPermission();
if (hasPermission) {
await _syncTrashedAssets();
} else {
_log.warning("syncTrashedAssets cannot proceed because MANAGE_MEDIA permission is missing");
}
}
if (CurrentPlatform.isIOS) {
// final assets = await _localAssetRepository.getEmptyCloudIdAssets();
// await _mapIosCloudIds(assets);
}
if (full || await _nativeSyncApi.shouldFullSync()) {
_log.fine("Full sync request from ${full ? "user" : "native"}");
return await fullSync();
@@ -39,8 +74,9 @@ class LocalSyncService {
final deviceAlbums = await _nativeSyncApi.getAlbums();
await _localAlbumRepository.updateAll(deviceAlbums.toLocalAlbums());
final newAssets = delta.updates.toLocalAssets();
await _localAlbumRepository.processDelta(
updates: delta.updates.toLocalAssets(),
updates: newAssets,
deletes: delta.deletes,
assetAlbums: delta.assetAlbums,
);
@@ -68,8 +104,9 @@ class LocalSyncService {
}
await updateAlbum(dbAlbum, album);
}
}
await _mapIosCloudIds(newAssets);
}
await _nativeSyncApi.checkpointSync();
} catch (e, s) {
_log.severe("Error performing device sync", e, s);
@@ -107,9 +144,12 @@ 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).then((a) => a.toLocalAssets())
: <LocalAsset>[];
await _localAlbumRepository.upsert(album, toUpsert: assets.toLocalAssets());
await _localAlbumRepository.upsert(album, toUpsert: assets);
await _mapIosCloudIds(assets);
_log.fine("Successfully added device album ${album.name}");
} catch (e, s) {
_log.warning("Error while adding device album", e, s);
@@ -179,13 +219,16 @@ class LocalSyncService {
return false;
}
final newAssets = await _nativeSyncApi.getAssetsForAlbum(deviceAlbum.id, updatedTimeCond: updatedTime);
final newAssets = await _nativeSyncApi
.getAssetsForAlbum(deviceAlbum.id, updatedTimeCond: updatedTime)
.then((a) => a.toLocalAssets());
await _localAlbumRepository.upsert(
deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection),
toUpsert: newAssets.toLocalAssets(),
toUpsert: newAssets,
);
await _mapIosCloudIds(newAssets);
return true;
} catch (e, s) {
_log.warning("Error on fast syncing local album: ${dbAlbum.name}", e, s);
@@ -217,6 +260,7 @@ class LocalSyncService {
if (dbAlbum.assetCount == 0) {
_log.fine("Device album ${deviceAlbum.name} is empty. Adding assets to DB.");
await _localAlbumRepository.upsert(updatedDeviceAlbum, toUpsert: assetsInDevice);
await _mapIosCloudIds(assetsInDevice);
return true;
}
@@ -254,6 +298,7 @@ class LocalSyncService {
}
await _localAlbumRepository.upsert(updatedDeviceAlbum, toUpsert: assetsToUpsert, toDelete: assetsToDelete);
await _mapIosCloudIds(assetsToUpsert);
return true;
} catch (e, s) {
@@ -262,17 +307,95 @@ class LocalSyncService {
return true;
}
// ignore: avoid-unused-parameters
Future<void> _mapIosCloudIds(List<LocalAsset> assets) async {
// if (!CurrentPlatform.isIOS || assets.isEmpty) {
return;
// }
// final assetIds = assets.map((a) => a.id).toList();
// final cloudMapping = <String, String>{};
// final cloudIds = await _nativeSyncApi.getCloudIdForAssetIds(assetIds);
// for (int i = 0; i < cloudIds.length; i++) {
// final cloudIdResult = cloudIds[i];
// if (cloudIdResult.cloudId != null) {
// cloudMapping[cloudIdResult.assetId] = cloudIdResult.cloudId!;
// } else {
// final asset = assets.firstWhereOrNull((a) => a.id == cloudIdResult.assetId);
// _log.fine(
// "Cannot fetch cloudId for asset with id: ${cloudIdResult.assetId}, name: ${asset?.name}, createdAt: ${asset?.createdAt}. Error: ${cloudIdResult.error ?? "unknown"}",
// );
// }
// }
// await _localAlbumRepository.updateCloudMapping(cloudMapping);
}
bool _assetsEqual(LocalAsset a, LocalAsset b) {
return a.updatedAt.isAtSameMomentAs(b.updatedAt) &&
if (CurrentPlatform.isAndroid) {
return a.updatedAt.isAtSameMomentAs(b.updatedAt) &&
a.createdAt.isAtSameMomentAs(b.createdAt) &&
a.width == b.width &&
a.height == b.height &&
a.durationInSeconds == b.durationInSeconds;
}
final firstAdjustment = a.adjustmentTime?.millisecondsSinceEpoch ?? 0;
final secondAdjustment = b.adjustmentTime?.millisecondsSinceEpoch ?? 0;
return firstAdjustment == secondAdjustment &&
a.createdAt.isAtSameMomentAs(b.createdAt) &&
a.width == b.width &&
a.height == b.height &&
a.durationInSeconds == b.durationInSeconds;
a.durationInSeconds == b.durationInSeconds &&
a.latitude == b.latitude &&
a.longitude == b.longitude;
}
bool _albumsEqual(LocalAlbum a, LocalAlbum b) {
return a.name == b.name && a.assetCount == b.assetCount && a.updatedAt.isAtSameMomentAs(b.updatedAt);
}
Future<void> _syncTrashedAssets() async {
final trashedAssetMap = await _nativeSyncApi.getTrashedAssets();
await processTrashedAssets(trashedAssetMap);
}
@visibleForTesting
Future<void> processTrashedAssets(Map<String, List<PlatformAsset>> trashedAssetMap) async {
if (trashedAssetMap.isEmpty) {
_log.info("syncTrashedAssets, No trashed assets found");
}
final trashedAssets = trashedAssetMap.cast<String, List<Object?>>().entries.expand(
(entry) => entry.value.cast<PlatformAsset>().toTrashedAssets(entry.key),
);
_log.fine("syncTrashedAssets, trashedAssets: ${trashedAssets.map((e) => e.asset.id)}");
await _trashedLocalAssetRepository.processTrashSnapshot(trashedAssets);
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
if (assetsToRestore.isNotEmpty) {
final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
} else {
_log.info("syncTrashedAssets, No remote assets found for restoration");
}
final localAssetsToTrash = await _trashedLocalAssetRepository.getToTrash();
if (localAssetsToTrash.isNotEmpty) {
final mediaUrls = await Future.wait(
localAssetsToTrash.values
.expand((e) => e)
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
);
_log.info("Moving to trash ${mediaUrls.join(", ")} assets");
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
if (result) {
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
}
} else {
_log.info("syncTrashedAssets, No assets found in backup-enabled albums for move to trash");
}
}
}
extension on Iterable<PlatformAlbum> {
@@ -283,6 +406,7 @@ extension on Iterable<PlatformAlbum> {
name: e.name,
updatedAt: tryFromSecondsSinceEpoch(e.updatedAt, isUtc: true) ?? DateTime.timestamp(),
assetCount: e.assetCount,
isIosSharedAlbum: e.isCloud,
),
).toList();
}
@@ -290,20 +414,40 @@ extension on Iterable<PlatformAlbum> {
extension on Iterable<PlatformAsset> {
List<LocalAsset> toLocalAssets() {
return map(
(e) => LocalAsset(
id: e.id,
name: e.name,
checksum: null,
type: AssetType.values.elementAtOrNull(e.type) ?? AssetType.other,
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();
return map((e) => e.toLocalAsset()).toList();
}
Iterable<TrashedAsset> toTrashedAssets(String albumId) {
return map((e) => (albumId: albumId, asset: e.toLocalAsset()));
}
}
extension PlatformToLocalAsset on PlatformAsset {
LocalAsset toLocalAsset() => LocalAsset(
id: id,
name: name,
checksum: null,
type: AssetType.values.elementAtOrNull(type) ?? AssetType.other,
createdAt: tryFromSecondsSinceEpoch(createdAt, isUtc: true) ?? DateTime.timestamp(),
updatedAt: tryFromSecondsSinceEpoch(updatedAt, isUtc: true) ?? DateTime.timestamp(),
width: width,
height: height,
durationInSeconds: durationInSeconds,
isFavorite: isFavorite,
orientation: orientation,
playbackStyle: _toPlaybackStyle(playbackStyle),
adjustmentTime: tryFromSecondsSinceEpoch(adjustmentTime, isUtc: true),
latitude: latitude,
longitude: longitude,
isEdited: false,
);
}
AssetPlaybackStyle _toPlaybackStyle(PlatformAssetPlaybackStyle style) => switch (style) {
PlatformAssetPlaybackStyle.unknown => AssetPlaybackStyle.unknown,
PlatformAssetPlaybackStyle.image => AssetPlaybackStyle.image,
PlatformAssetPlaybackStyle.video => AssetPlaybackStyle.video,
PlatformAssetPlaybackStyle.imageAnimated => AssetPlaybackStyle.imageAnimated,
PlatformAssetPlaybackStyle.livePhoto => AssetPlaybackStyle.livePhoto,
PlatformAssetPlaybackStyle.videoLooping => AssetPlaybackStyle.videoLooping,
};
+3 -1
View File
@@ -1,5 +1,6 @@
import 'package:immich_mobile/domain/models/map.model.dart';
import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
typedef MapMarkerSource = Future<List<Marker>> Function(LatLngBounds? bounds);
@@ -11,7 +12,8 @@ class MapFactory {
const MapFactory({required DriftMapRepository mapRepository}) : _mapRepository = mapRepository;
MapService remote(String ownerId) => MapService(_mapRepository.remote(ownerId));
MapService remote(List<String> ownerIds, TimelineMapOptions options) =>
MapService(_mapRepository.remote(ownerIds, options));
}
class MapService {
@@ -10,6 +10,10 @@ class DriftPeopleService {
const DriftPeopleService(this._repository, this._personApiRepository);
Future<DriftPerson?> get(String personId) {
return _repository.get(personId);
}
Future<List<DriftPerson>> getAssetPeople(String assetId) {
return _repository.getAssetPeople(assetId);
}
@@ -1,12 +1,14 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
class RemoteAlbumService {
final DriftRemoteAlbumRepository _repository;
@@ -32,19 +34,21 @@ class RemoteAlbumService {
Future<List<RemoteAlbum>> sortAlbums(
List<RemoteAlbum> albums,
RemoteAlbumSortMode sortMode, {
AlbumSortMode sortMode, {
bool isReverse = false,
}) async {
// list of albums sorted ascendingly according to the selected sort mode
final List<RemoteAlbum> sorted = switch (sortMode) {
RemoteAlbumSortMode.created => albums.sortedBy((album) => album.createdAt),
RemoteAlbumSortMode.title => albums.sortedBy((album) => album.name),
RemoteAlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt),
RemoteAlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount),
RemoteAlbumSortMode.mostRecent => await _sortByNewestAsset(albums),
RemoteAlbumSortMode.mostOldest => await _sortByOldestAsset(albums),
AlbumSortMode.created => albums.sortedBy((album) => album.createdAt),
AlbumSortMode.title => albums.sortedBy((album) => album.name),
AlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt),
AlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount),
AlbumSortMode.mostRecent => await _sortByAssetDate(albums, aggregation: AssetDateAggregation.end),
AlbumSortMode.mostOldest => await _sortByAssetDate(albums, aggregation: AssetDateAggregation.start),
};
final effectiveOrder = isReverse ? sortMode.defaultOrder.reverse() : sortMode.defaultOrder;
return (isReverse ? sorted.reversed : sorted).toList();
return (effectiveOrder == SortOrder.asc ? sorted : sorted.reversed).toList();
}
List<RemoteAlbum> searchAlbums(
@@ -168,59 +172,25 @@ class RemoteAlbumService {
return _repository.getAlbumsContainingAsset(assetId);
}
Future<List<RemoteAlbum>> _sortByNewestAsset(List<RemoteAlbum> albums) async {
// map album IDs to their newest asset dates
final Map<String, Future<DateTime?>> assetTimestampFutures = {};
for (final album in albums) {
assetTimestampFutures[album.id] = _repository.getNewestAssetTimestamp(album.id);
Future<List<RemoteAlbum>> _sortByAssetDate(
List<RemoteAlbum> albums, {
required AssetDateAggregation aggregation,
}) async {
if (albums.isEmpty) return [];
final albumIds = albums.map((e) => e.id).toList();
final sortedIds = await _repository.getSortedAlbumIds(albumIds, aggregation: aggregation);
final albumMap = Map<String, RemoteAlbum>.fromEntries(albums.map((a) => MapEntry(a.id, a)));
final sortedAlbums = sortedIds.map((id) => albumMap[id]).whereType<RemoteAlbum>().toList();
if (sortedAlbums.length < albums.length) {
final returnedIdSet = sortedIds.toSet();
final emptyAlbums = albums.where((a) => !returnedIdSet.contains(a.id));
sortedAlbums.addAll(emptyAlbums);
}
// await all database queries
final entries = await Future.wait(
assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)),
);
final assetTimestamps = Map.fromEntries(entries);
final sorted = albums.sorted((a, b) {
final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
return aDate.compareTo(bDate);
});
return sorted;
}
Future<List<RemoteAlbum>> _sortByOldestAsset(List<RemoteAlbum> albums) async {
// map album IDs to their oldest asset dates
final Map<String, Future<DateTime?>> assetTimestampFutures = {
for (final album in albums) album.id: _repository.getOldestAssetTimestamp(album.id),
};
// await all database queries
final entries = await Future.wait(
assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)),
);
final assetTimestamps = Map.fromEntries(entries);
final sorted = albums.sorted((a, b) {
final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
return aDate.compareTo(bDate);
});
return sorted.reversed.toList();
return sortedAlbums;
}
}
enum RemoteAlbumSortMode {
title("library_page_sort_title"),
assetCount("library_page_sort_asset_count"),
lastModified("library_page_sort_last_modified"),
created("library_page_sort_created"),
mostRecent("sort_newest"),
mostOldest("sort_oldest");
final String key;
const RemoteAlbumSortMode(this.key);
}
@@ -70,13 +70,15 @@ extension on AssetResponseDto {
_ => AssetVisibility.timeline,
},
durationInSeconds: duration.toDuration()?.inSeconds ?? 0,
height: exifInfo?.exifImageHeight?.toInt(),
width: exifInfo?.exifImageWidth?.toInt(),
height: height?.toInt(),
width: width?.toInt(),
isFavorite: isFavorite,
livePhotoVideoId: livePhotoVideoId,
thumbHash: thumbhash,
localId: null,
type: type.toAssetType(),
stackId: stack?.id,
isEdited: isEdited,
);
}
}

Some files were not shown because too many files have changed in this diff Show More