mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
merge main
This commit is contained in:
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"flutter": "3.35.7"
|
||||
}
|
||||
+1
-4
@@ -55,8 +55,5 @@ default.isar
|
||||
default.isar.lock
|
||||
libisar.so
|
||||
|
||||
# FVM Version
|
||||
.fvm/
|
||||
|
||||
# Translation file
|
||||
lib/generated/
|
||||
lib/generated/
|
||||
|
||||
Vendored
+3
-1
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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/" />
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+20
-19
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
-52
@@ -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>
|
||||
@@ -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
@@ -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 @@
|
||||
version: '>=1.29.0 <=1.30.0'
|
||||
version: '>=1.29.0 <=1.36.0'
|
||||
|
||||
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -33,4 +33,5 @@ Runner/GeneratedPluginRegistrant.*
|
||||
!default.perspectivev3
|
||||
|
||||
fastlane/report.xml
|
||||
Gemfile.lock
|
||||
Gemfile.lock
|
||||
certs/
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 */;
|
||||
}
|
||||
|
||||
+177
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+22
-21
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user