Compare commits
30 Commits
docs-2026
...
feat/mobil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c531b95d54 | ||
|
|
82dde1c3e7 | ||
|
|
fe07cf6f6d | ||
|
|
87a1502ef1 | ||
|
|
cef5a7cf50 | ||
|
|
9980d06f0c | ||
|
|
e51547cd28 | ||
|
|
d898cba04d | ||
|
|
fd89607142 | ||
|
|
95fad974c1 | ||
|
|
00ea98479f | ||
|
|
24ff08e094 | ||
|
|
495cd8a6de | ||
|
|
f98b441100 | ||
|
|
e739aa1667 | ||
|
|
d83e74dc88 | ||
|
|
06467f2c0d | ||
|
|
957ee84636 | ||
|
|
8ad93ab4b1 | ||
|
|
89f9c136ac | ||
|
|
4ec0d6df9a | ||
|
|
6cba10513b | ||
|
|
2d4ee03c5b | ||
|
|
6e956bfe4c | ||
|
|
1e0e0d8066 | ||
|
|
66a0e04489 | ||
|
|
9cae61851c | ||
|
|
34f7e07be9 | ||
|
|
e098a8261e | ||
|
|
2f1d1edf10 |
@@ -5,7 +5,9 @@ import TabItem from '@theme/TabItem';
|
||||
|
||||
A [3-2-1 backup strategy](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) is recommended to protect your data. You should keep copies of your uploaded photos/videos as well as the Immich database for a comprehensive backup solution. This page provides an overview on how to backup the database and the location of user-uploaded pictures and videos. A template bash script that can be run as a cron job is provided [here](/guides/template-backup-script.md)
|
||||
|
||||
The instructions on this page show you how to prepare your Immich instance to be backed up, and which files to take a backup of. _You still need to take care of using an actual backup tool to make a backup yourself._
|
||||
:::danger
|
||||
The instructions on this page show you how to prepare your Immich instance to be backed up, and which files to take a backup of. You still need to take care of using an actual backup tool to make a backup yourself.
|
||||
:::
|
||||
|
||||
## Database
|
||||
|
||||
|
||||
@@ -1,42 +1,37 @@
|
||||
# Automatic Backup
|
||||
|
||||
## Overview
|
||||
|
||||
Immich supports uploading photos and videos from your mobile device to the server automatically.
|
||||
|
||||
By enable the backup button, Immich will upload new photos and videos from selected albums when you open or resume the app, as well as periodically in the background (iOS), or when the a new photos or videos are taken (Android).
|
||||
---
|
||||
|
||||
<img
|
||||
src={require('./img/enable-backup-button.webp').default}
|
||||
width="300px"
|
||||
title="Upload button"
|
||||
/>
|
||||
You can enable the settings by accessing the upload options from the upload page
|
||||
|
||||
## Platform Specific Features
|
||||
<img src={require('./img/backup-settings-access.webp').default} width="50%" title="Backup option selection" />
|
||||
|
||||
### General
|
||||
<img src={require('./img/background-foreground-backup.webp').default} width="50%" title="Foreground&Background Backup" />
|
||||
|
||||
By default, Immich will only upload photos and videos when connected to Wi-Fi. You can change this behavior in the backup settings page.
|
||||
## Foreground backup
|
||||
|
||||
<img
|
||||
src={require('./img/backup-options.webp').default}
|
||||
width="500px"
|
||||
title="Upload button"
|
||||
/>
|
||||
If foreground backup is enabled: whenever the app is opened or resumed, it will check if any photos or videos in the selected album(s) have yet to be uploaded to the cloud (the remainder count). If there are any, they will be uploaded.
|
||||
|
||||
### Android
|
||||
## Background backup
|
||||
|
||||
<img
|
||||
src={require('./img/android-backup-options.webp').default}
|
||||
width="500px"
|
||||
title="Upload button"
|
||||
/>
|
||||
This feature is intended for everyday use. For initial bulk uploading, please use the foreground upload feature. For more information on why background upload is not working as expected, please refer to the [FAQ](/FAQ#why-does-foreground-backup-stop-when-i-navigate-away-from-the-app-shouldnt-it-transfer-the-job-to-background-backup).
|
||||
|
||||
If background backup is enabled. The app will periodically check if there are any new photos or videos in the selected album(s) to be uploaded to the server. If there are, it will upload them to the cloud in the background.
|
||||
|
||||
:::info Note
|
||||
|
||||
#### General
|
||||
|
||||
- The app must be in the background for the backup worker to start running.
|
||||
- If you reopen the app and the first page you see is the backup page, the counts will not reflect the background uploaded result. You have to navigate out of the page and come back to see the updated counts.
|
||||
|
||||
#### Android
|
||||
|
||||
- It is a well-known problem that some Android models are very strict with battery optimization settings, which can cause a problem with the background worker. Please visit [Don't kill my app](https://dontkillmyapp.com/) for a guide on disabling this setting on your phone.
|
||||
- You can allow the background task to run when the device is charging.
|
||||
- You can set the minimum delay from the time a photo is taken to when the background upload task will run.
|
||||
|
||||
### iOS
|
||||
#### iOS
|
||||
|
||||
- You must enable **Background App Refresh** for the app to work in the background. You can enable it in the Settings app under General > Background App Refresh.
|
||||
|
||||
@@ -44,4 +39,4 @@ title="Upload button"
|
||||
<img src={require('./img/background-app-refresh.webp').default} width="30%" title="background-app-refresh" />
|
||||
</div>
|
||||
|
||||
- iOS automatically manages background tasks, the app cannot control when the background upload task will run. It is known that the more frequently you open the app, the more often the background task will run.
|
||||
:::
|
||||
|
||||
@@ -188,8 +188,6 @@ immich upload --dry-run . | tail -n +6 | jq .newFiles[]
|
||||
|
||||
### Obtain the API Key
|
||||
|
||||
The API key can be obtained in the user setting panel on the web interface. You can also specify permissions for the key to limit its access.
|
||||
The API key can be obtained in the user setting panel on the web interface.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
@@ -21,14 +21,14 @@ The asset detail view will also show the faces that are recognized in the asset.
|
||||
Additional actions you can do include:
|
||||
|
||||
- Changing the feature photo of the person
|
||||
- Hiding the faces of a person from the Explore page and detail view
|
||||
- Setting a person's date of birth, so that the age of the person can be shown at the time the photo was taken
|
||||
- Setting a person's date of birth
|
||||
- Merging two or more detected faces into one person
|
||||
- Favoriting a person to pin them to the top of the list
|
||||
- Hiding the faces of a person from the Explore page and detail view
|
||||
- Assigning an unrecognized face to a person
|
||||
|
||||
It can be found from the app bar when you access the detail view of a person.
|
||||
|
||||
<img src={require('./img/facial-recognition-4.webp').default} title='Facial Recognition 4' />
|
||||
<img src={require('./img/facial-recognition-4.webp').default} title='Facial Recognition 4' width="70%"/>
|
||||
|
||||
## How Face Detection Works
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 16 KiB |
BIN
docs/docs/features/img/background-foreground-backup.webp
Normal file
|
After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 286 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 231 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 319 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 85 KiB |
@@ -20,6 +20,14 @@ Below are the SHA-256 fingerprints for the certificates signing the android appl
|
||||
|
||||
:::
|
||||
|
||||
:::info Beta Program
|
||||
The beta release channel allows users to test upcoming changes before they are officially released. To join the channel use the links below.
|
||||
|
||||
- Android: Invitation link from [web](https://play.google.com/store/apps/details?id=app.alextran.immich) or from [mobile](https://play.google.com/store/apps/details?id=app.alextran.immich)
|
||||
- iOS: [TestFlight invitation link](https://testflight.apple.com/join/1vYsAa8P)
|
||||
|
||||
:::
|
||||
|
||||
## Login
|
||||
|
||||
<MobileAppLogin />
|
||||
@@ -28,6 +36,10 @@ Below are the SHA-256 fingerprints for the certificates signing the android appl
|
||||
|
||||
<MobileAppBackup />
|
||||
|
||||
:::info
|
||||
You can enable automatic backup on supported devices. For more information see [Automatic Backup](/features/automatic-backup.md).
|
||||
:::
|
||||
|
||||
## Sync only selected photos
|
||||
|
||||
If you have a large number of photos on the device, and you would prefer not to backup all the photos, then it might be prudent to only backup selected photos from device to the Immich server.
|
||||
@@ -45,6 +57,11 @@ This will enable a small cloud icon on the bottom right corner of the asset tile
|
||||
|
||||
Now make sure that the local album is selected in the backup screen (steps 1-2 above). You can find these albums listed in **<ins>Library -> On this device</ins>**. To selectively upload photos from these albums, simply select the local-only photos and tap on "Upload" button in the dynamic bottom menu.
|
||||
|
||||
<img
|
||||
src={require('./img/mobile-upload-open-photo.webp').default}
|
||||
width="50%"
|
||||
title="Upload button on local asset preview"
|
||||
/>
|
||||
<img
|
||||
src={require('./img/mobile-upload-selected-photos.webp').default}
|
||||
width="40%"
|
||||
@@ -57,32 +74,49 @@ The **Free Up Space** tool allows you to remove local media files from your devi
|
||||
|
||||
### How it works
|
||||
|
||||
<img src={require('./img/free-up-space.webp').default} title="Free up space" />
|
||||
|
||||
1. **Configuration:**
|
||||
- **Cutoff Date:** You can select a cutoff date. The tool will only look for photos and videos **on or before** this date.
|
||||
- **Filter Options:** You can choose to remove **All** assets, or restrict removal to **Photos only** or **Videos only**.
|
||||
- **Keep Favorites:** By default, local assets marked as favorites are preserved on your device, even if they match the cutoff date.
|
||||
2. **Scan & Review:** Before any files are removed, you are presented with a review screen to verify which items will be deleted.
|
||||
3. **Deletion:** Confirmed items are moved to your device's native Trash/Recycle Bin.
|
||||
3. **Deletion:** Confirmed items are moved to your device's native Trash/Recycle Bin. They will be permanently removed by the OS based on your system settings (usually after 30 days).
|
||||
|
||||
:::info reclaim storage
|
||||
To permanently free up space, you must manually empty the system/gallery trash.
|
||||
:::info Android Permissions
|
||||
For the smoothest experience on Android, you should grant Immich special delete privileges. Without this, you may be prompted to confirm deletion for every single image.
|
||||
|
||||
Go to **Immich Settings > Advanced** and enable **"Media Management Access"**.
|
||||
:::
|
||||
|
||||
### iCloud Photos
|
||||
### iCloud Photos (iOS Users)
|
||||
|
||||
If you use **iCloud Photos** alongside Immich, it is vital to understand how deletion affects your data. iCloud utilizes a two-way sync; this means deleting a photo from your iPhone to free up space will **also delete it from iCloud**.
|
||||
|
||||
Assets that are part of an **iCloud Shared Album** are automatically excluded from the cleanup scan because iCloud does not allow removing the items from the device.
|
||||
:::warning iCloud & Backups
|
||||
If you rely on iCloud as a secondary backup (part of a 3-2-1 backup strategy), using the Free Up Space feature in Immich will remove the file from both your phone and iCloud.
|
||||
|
||||
Once deleted, the photo will exist **only** on your Immich server (and your phone's "Recently Deleted" folder for 30 days).
|
||||
|
||||
When you use iCloud Photos and delete a photo or video on one device, it's also deleted on all other devices where you're signed in with the same Apple Account.
|
||||
|
||||
More information on the [Apple Support](https://support.apple.com/en-us/108922#iCloud_photo_library) website
|
||||
|
||||
**Shared Albums**
|
||||
Assets that are part of an **iCloud Shared Album** are automatically excluded from the cleanup scan to ensure they remain viewable to others in the shared album.
|
||||
:::
|
||||
|
||||
### External App Dependencies (WhatsApp, etc.)
|
||||
|
||||
:::danger WhatsApp & Local Files
|
||||
Android applications like **WhatsApp** rely on local files to display media in chat history.
|
||||
|
||||
If Immich backs up your WhatsApp folder and you run **Free Up Space**, the local copies of these images will be deleted. Consequently, **media in your WhatsApp chats will appear blurry or missing.** You will only be able to view these photos inside the Immich app; they will no longer be visible within the WhatsApp interface.
|
||||
|
||||
**Recommendation:** If keeping chat history intact is important, please ensure you review the deletion list carefully or consider excluding WhatsApp folders from the backup if you intend to use this feature frequently.
|
||||
:::
|
||||
|
||||
:::info reclaim storage
|
||||
You must empty the system/gallery trash manually to reclaim storage.
|
||||
:::
|
||||
|
||||
## Album Sync
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 7.6 KiB |
@@ -17,11 +17,11 @@ If this does not work, try running `docker compose up -d --force-recreate`.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
| Variable | Description | Default | Containers |
|
||||
| :----------------- | :------------------------------ | :-----: | :----------------------- |
|
||||
| `IMMICH_VERSION` | Image tags | `v2` | server, machine learning |
|
||||
| `UPLOAD_LOCATION` | Host path for uploads | | server |
|
||||
| `DB_DATA_LOCATION` | Host path for Postgres database | | database |
|
||||
| Variable | Description | Default | Containers |
|
||||
| :----------------- | :------------------------------ | :-------: | :----------------------- |
|
||||
| `IMMICH_VERSION` | Image tags | `release` | server, machine learning |
|
||||
| `UPLOAD_LOCATION` | Host path for uploads | | server |
|
||||
| `DB_DATA_LOCATION` | Host path for Postgres database | | database |
|
||||
|
||||
:::tip
|
||||
These environment variables are used by the `docker-compose.yml` file and do **NOT** affect the containers directly.
|
||||
|
||||
@@ -10,7 +10,7 @@ to install and use it.
|
||||
|
||||
## Requirements
|
||||
|
||||
- A system with at least 6GB of RAM and 2 CPU cores.
|
||||
- A system with at least 4GB of RAM and 2 CPU cores.
|
||||
- [Docker](https://docs.docker.com/engine/install/)
|
||||
|
||||
> For a more detailed list of requirements, see the [requirements page](/install/requirements).
|
||||
@@ -63,9 +63,9 @@ The backup time differs depending on how many photos are on your mobile device.
|
||||
take quite a while.
|
||||
To quickly get going, you can selectively upload few photos first, by following this [guide](/features/mobile-app#sync-only-selected-photos).
|
||||
|
||||
You can select the **Job Queues** tab to see Immich processing your photos.
|
||||
You can select the **Jobs** tab to see Immich processing your photos.
|
||||
|
||||
<img src={require('/docs/guides/img/jobs-tab.webp').default} title="Job Queues Tah" width={300} />
|
||||
<img src={require('/docs/guides/img/jobs-tab.webp').default} title="Jobs tab" width={300} />
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -6,4 +6,4 @@
|
||||
|
||||
<img src={require('./img/album-selection.webp').default} width='50%' title='Backup button' />
|
||||
|
||||
3. Scroll down to the bottom and press "**Enable Backup**" to start the backup process. This will upload all the assets in the selected albums.
|
||||
3. Scroll down to the bottom and press "**Start Backup**" to start the backup process. This will upload all the assets in the selected albums.
|
||||
|
||||
@@ -2,6 +2,6 @@ If you have friends or family members who want to use the application as well, y
|
||||
|
||||
<img src={require('./img/create-new-user.webp').default} width="90%" title='New User Registration' />
|
||||
|
||||
In the **Administration > Users** page, you can click on the **Create user** button, and you'll be presented with the following dialog:
|
||||
In the Administration panel, you can click on the **Create user** button, and you'll be presented with the following dialog:
|
||||
|
||||
<img src={require('./img/create-new-user-dialog.webp').default} width="40%" title='New User Registration Dialog' />
|
||||
<img src={require('./img/create-new-user-dialog.webp').default} width="90%" title='New User Registration Dialog' />
|
||||
|
||||
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 260 KiB After Width: | Height: | Size: 105 KiB |
@@ -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)
|
||||
|
||||
@@ -31,7 +31,7 @@ if (keystorePropertiesFile.exists()) {
|
||||
|
||||
android {
|
||||
compileSdkVersion 35
|
||||
ndkVersion = "28.1.13356709"
|
||||
ndkVersion = "28.2.13676358"
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
@@ -48,6 +48,7 @@ android {
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig true
|
||||
compose true
|
||||
}
|
||||
|
||||
@@ -105,8 +106,11 @@ 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 "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
|
||||
implementation "androidx.work:work-runtime-ktx:$work_version"
|
||||
implementation "androidx.concurrent:concurrent-futures:$concurrent_version"
|
||||
|
||||
@@ -1,40 +1,38 @@
|
||||
#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);
|
||||
}
|
||||
|
||||
JNIEXPORT jobject JNICALL
|
||||
Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_wrapAsBuffer(
|
||||
JNIEnv *env, jclass clazz, jlong address, jint capacity) {
|
||||
return (*env)->NewDirectByteBuffer(env, (void *) address, capacity);
|
||||
}
|
||||
|
||||
JNIEXPORT jobject JNICALL
|
||||
Java_app_alextran_immich_images_ThumbnailsImpl_wrapAsBuffer(
|
||||
JNIEnv *env, jclass clazz, jlong address, jint capacity) {
|
||||
return (*env)->NewDirectByteBuffer(env, (void *) address, capacity);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package app.alextran.immich
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import app.alextran.immich.core.SSLConfig
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
@@ -51,15 +52,18 @@ class HttpSSLOptionsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
when (call.method) {
|
||||
"apply" -> {
|
||||
val args = call.arguments<ArrayList<*>>()!!
|
||||
val allowSelfSigned = args[0] as Boolean
|
||||
val serverHost = args[1] as? String
|
||||
val clientCertHash = (args[2] as? ByteArray)
|
||||
|
||||
var tm: Array<TrustManager>? = null
|
||||
if (args[0] as Boolean) {
|
||||
tm = arrayOf(AllowSelfSignedTrustManager(args[1] as? String))
|
||||
if (allowSelfSigned) {
|
||||
tm = arrayOf(AllowSelfSignedTrustManager(serverHost))
|
||||
}
|
||||
|
||||
var km: Array<KeyManager>? = null
|
||||
if (args[2] != null) {
|
||||
val cert = ByteArrayInputStream(args[2] as ByteArray)
|
||||
if (clientCertHash != null) {
|
||||
val cert = ByteArrayInputStream(clientCertHash)
|
||||
val password = (args[3] as String).toCharArray()
|
||||
val keyStore = KeyStore.getInstance("PKCS12")
|
||||
keyStore.load(cert, password)
|
||||
@@ -69,6 +73,9 @@ class HttpSSLOptionsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
km = keyManagerFactory.keyManagers
|
||||
}
|
||||
|
||||
// Update shared SSL config for OkHttp and other HTTP clients
|
||||
SSLConfig.apply(km, tm, allowSelfSigned, serverHost, clientCertHash?.contentHashCode() ?: 0)
|
||||
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(km, tm, null)
|
||||
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
|
||||
|
||||
@@ -10,8 +10,10 @@ import app.alextran.immich.background.BackgroundWorkerLockApi
|
||||
import app.alextran.immich.connectivity.ConnectivityApi
|
||||
import app.alextran.immich.connectivity.ConnectivityApiImpl
|
||||
import app.alextran.immich.core.ImmichPlugin
|
||||
import app.alextran.immich.images.ThumbnailApi
|
||||
import app.alextran.immich.images.ThumbnailsImpl
|
||||
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
|
||||
@@ -36,7 +38,9 @@ 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))
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
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)
|
||||
}
|
||||
|
||||
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,73 @@
|
||||
package app.alextran.immich.core
|
||||
|
||||
import java.security.KeyStore
|
||||
import javax.net.ssl.KeyManager
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
/**
|
||||
* Shared SSL configuration for OkHttp and HttpsURLConnection.
|
||||
* Stores the SSLSocketFactory and X509TrustManager configured by HttpSSLOptionsPlugin.
|
||||
*/
|
||||
object SSLConfig {
|
||||
var sslSocketFactory: SSLSocketFactory? = null
|
||||
private set
|
||||
|
||||
var trustManager: X509TrustManager? = null
|
||||
private set
|
||||
|
||||
var requiresCustomSSL: Boolean = false
|
||||
private set
|
||||
|
||||
private val listeners = mutableListOf<() -> Unit>()
|
||||
private var configHash: Int = 0
|
||||
|
||||
fun addListener(listener: () -> Unit) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
fun apply(
|
||||
keyManagers: Array<KeyManager>?,
|
||||
trustManagers: Array<TrustManager>?,
|
||||
allowSelfSigned: Boolean,
|
||||
serverHost: String?,
|
||||
clientCertHash: Int
|
||||
) {
|
||||
synchronized(this) {
|
||||
val newHash = computeHash(allowSelfSigned, serverHost, clientCertHash)
|
||||
val newRequiresCustomSSL = allowSelfSigned || keyManagers != null
|
||||
if (newHash == configHash && sslSocketFactory != null && requiresCustomSSL == newRequiresCustomSSL) {
|
||||
return // Config unchanged, skip
|
||||
}
|
||||
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(keyManagers, trustManagers, null)
|
||||
sslSocketFactory = sslContext.socketFactory
|
||||
trustManager = trustManagers?.filterIsInstance<X509TrustManager>()?.firstOrNull()
|
||||
?: getDefaultTrustManager()
|
||||
requiresCustomSSL = newRequiresCustomSSL
|
||||
configHash = newHash
|
||||
notifyListeners()
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeHash(allowSelfSigned: Boolean, serverHost: String?, clientCertHash: Int): Int {
|
||||
var result = allowSelfSigned.hashCode()
|
||||
result = 31 * result + (serverHost?.hashCode() ?: 0)
|
||||
result = 31 * result + clientCertHash
|
||||
return result
|
||||
}
|
||||
|
||||
private fun notifyListeners() {
|
||||
listeners.forEach { it() }
|
||||
}
|
||||
|
||||
private fun getDefaultTrustManager(): X509TrustManager {
|
||||
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
factory.init(null as KeyStore?)
|
||||
return factory.trustManagers.filterIsInstance<X509TrustManager>().first()
|
||||
}
|
||||
}
|
||||
@@ -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, 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,13 @@ 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>> ->
|
||||
api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg) { 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 +97,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 +115,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 +123,10 @@ interface ThumbnailApi {
|
||||
api.getThumbhash(thumbhashArg) { result: Result<Map<String, Long>> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(ThumbnailsPigeonUtils.wrapError(error))
|
||||
reply.reply(LocalImagesPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(ThumbnailsPigeonUtils.wrapResult(data))
|
||||
reply.reply(LocalImagesPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,8 @@ 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.util.concurrent.Executors
|
||||
import com.bumptech.glide.Glide
|
||||
@@ -26,10 +27,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)
|
||||
recycle()
|
||||
return mapOf(
|
||||
"pointer" to pointer,
|
||||
"width" to width.toLong(),
|
||||
"height" to height.toLong(),
|
||||
"rowBytes" to (width * 4).toLong()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
NativeBuffer.free(pointer)
|
||||
recycle()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
class LocalImagesImpl(context: Context) : LocalImageApi {
|
||||
private val ctx: Context = context.applicationContext
|
||||
private val resolver: ContentResolver = ctx.contentResolver
|
||||
private val requestThread = Executors.newSingleThreadExecutor()
|
||||
@@ -38,21 +71,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 +83,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,7 +99,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
|
||||
width: Long,
|
||||
height: Long,
|
||||
isVideo: Boolean,
|
||||
callback: (Result<Map<String, Long>>) -> Unit
|
||||
callback: (Result<Map<String, Long>?>) -> Unit
|
||||
) {
|
||||
val signal = CancellationSignal()
|
||||
val task = threadPool.submit {
|
||||
@@ -98,7 +119,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()
|
||||
@@ -117,7 +138,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
|
||||
width: Long,
|
||||
height: Long,
|
||||
isVideo: Boolean,
|
||||
callback: (Result<Map<String, Long>>) -> Unit,
|
||||
callback: (Result<Map<String, Long>?>) -> Unit,
|
||||
signal: CancellationSignal
|
||||
) {
|
||||
signal.throwIfCanceled()
|
||||
@@ -131,31 +152,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 +193,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,104 @@
|
||||
// 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, headers: Map<String, String>, requestId: Long, callback: (Result<Map<String, Long>?>) -> Unit)
|
||||
fun cancelRequest(requestId: Long)
|
||||
|
||||
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 headersArg = args[1] as Map<String, String>
|
||||
val requestIdArg = args[2] as Long
|
||||
api.requestImage(urlArg, headersArg, requestIdArg) { 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
package app.alextran.immich.images
|
||||
|
||||
import android.content.Context
|
||||
import android.os.CancellationSignal
|
||||
import android.os.OperationCanceledException
|
||||
import app.alextran.immich.BuildConfig
|
||||
import app.alextran.immich.INITIAL_BUFFER_SIZE
|
||||
import app.alextran.immich.NativeBuffer
|
||||
import app.alextran.immich.NativeByteBuffer
|
||||
import app.alextran.immich.core.SSLConfig
|
||||
import okhttp3.Cache
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.ConnectionPool
|
||||
import okhttp3.Dispatcher
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.chromium.net.CronetEngine
|
||||
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
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
|
||||
private const val USER_AGENT = "Immich_Android_${BuildConfig.VERSION_NAME}"
|
||||
private const val MAX_REQUESTS_PER_HOST = 64
|
||||
private const val KEEP_ALIVE_CONNECTIONS = 10
|
||||
private const val KEEP_ALIVE_DURATION_MINUTES = 5L
|
||||
private const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024
|
||||
|
||||
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,
|
||||
headers: Map<String, String>,
|
||||
requestId: Long,
|
||||
callback: (Result<Map<String, Long>?>) -> Unit
|
||||
) {
|
||||
val signal = CancellationSignal()
|
||||
requestMap[requestId] = RemoteRequest(signal)
|
||||
|
||||
ImageFetcherManager.fetch(
|
||||
url,
|
||||
headers,
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
private object ImageFetcherManager {
|
||||
private lateinit var appContext: Context
|
||||
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
|
||||
appContext = context.applicationContext
|
||||
cacheDir = context.cacheDir
|
||||
fetcher = build()
|
||||
SSLConfig.addListener(::invalidate)
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
fun fetch(
|
||||
url: String,
|
||||
headers: Map<String, String>,
|
||||
signal: CancellationSignal,
|
||||
onSuccess: (NativeByteBuffer) -> Unit,
|
||||
onFailure: (Exception) -> Unit,
|
||||
) {
|
||||
fetcher.fetch(url, headers, signal, onSuccess, onFailure)
|
||||
}
|
||||
|
||||
private fun invalidate() {
|
||||
synchronized(this) {
|
||||
val oldFetcher = fetcher
|
||||
if (oldFetcher is OkHttpImageFetcher && SSLConfig.requiresCustomSSL) {
|
||||
fetcher = oldFetcher.reconfigure(SSLConfig.sslSocketFactory, SSLConfig.trustManager)
|
||||
return
|
||||
}
|
||||
fetcher = build()
|
||||
oldFetcher.drain()
|
||||
}
|
||||
}
|
||||
|
||||
private fun build(): ImageFetcher {
|
||||
return if (SSLConfig.requiresCustomSSL) {
|
||||
OkHttpImageFetcher.create(cacheDir, SSLConfig.sslSocketFactory, SSLConfig.trustManager)
|
||||
} else {
|
||||
CronetImageFetcher(appContext, cacheDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface ImageFetcher {
|
||||
fun fetch(
|
||||
url: String,
|
||||
headers: Map<String, String>,
|
||||
signal: CancellationSignal,
|
||||
onSuccess: (NativeByteBuffer) -> Unit,
|
||||
onFailure: (Exception) -> Unit,
|
||||
)
|
||||
|
||||
fun drain()
|
||||
}
|
||||
|
||||
private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher {
|
||||
private val engine: CronetEngine
|
||||
private val executor = Executors.newFixedThreadPool(4)
|
||||
private val stateLock = Any()
|
||||
private var activeCount = 0
|
||||
private var draining = false
|
||||
|
||||
init {
|
||||
val storageDir = File(cacheDir, "cronet").apply { mkdirs() }
|
||||
engine = CronetEngine.Builder(context)
|
||||
.enableHttp2(true)
|
||||
.enableQuic(true)
|
||||
.enableBrotli(true)
|
||||
.setStoragePath(storageDir.absolutePath)
|
||||
.setUserAgent(USER_AGENT)
|
||||
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, CACHE_SIZE_BYTES)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun fetch(
|
||||
url: String,
|
||||
headers: Map<String, 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 = engine.newUrlRequestBuilder(url, callback, executor)
|
||||
headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
|
||||
val request = requestBuilder.build()
|
||||
signal.setOnCancelListener(request::cancel)
|
||||
request.start()
|
||||
}
|
||||
|
||||
private fun onComplete() {
|
||||
val shouldShutdown = synchronized(stateLock) {
|
||||
activeCount--
|
||||
draining && activeCount == 0
|
||||
}
|
||||
if (shouldShutdown) {
|
||||
engine.shutdown()
|
||||
executor.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
override fun drain() {
|
||||
val shouldShutdown = synchronized(stateLock) {
|
||||
if (draining) return
|
||||
draining = true
|
||||
activeCount == 0
|
||||
}
|
||||
if (shouldShutdown) {
|
||||
engine.shutdown()
|
||||
executor.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
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 httpError: IOException? = 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) {
|
||||
httpError = IOException("HTTP ${info.httpStatusCode}: ${info.httpStatusText}")
|
||||
return request.cancel()
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReadCompleted(
|
||||
request: UrlRequest,
|
||||
info: UrlResponseInfo,
|
||||
byteBuffer: ByteBuffer
|
||||
) {
|
||||
val buf = if (wrapped == null) {
|
||||
buffer!!.run {
|
||||
advance(byteBuffer.position())
|
||||
ensureHeadroom()
|
||||
wrapRemaining()
|
||||
}
|
||||
} else {
|
||||
wrapped
|
||||
}
|
||||
request.read(buf)
|
||||
}
|
||||
|
||||
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(httpError ?: 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,
|
||||
sslSocketFactory: SSLSocketFactory?,
|
||||
trustManager: X509TrustManager?,
|
||||
): OkHttpImageFetcher {
|
||||
val dir = File(cacheDir, "okhttp")
|
||||
val connectionPool = ConnectionPool(
|
||||
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
|
||||
keepAliveDuration = KEEP_ALIVE_DURATION_MINUTES,
|
||||
timeUnit = TimeUnit.MINUTES
|
||||
)
|
||||
|
||||
val builder = OkHttpClient.Builder()
|
||||
.addInterceptor { chain ->
|
||||
chain.proceed(
|
||||
chain.request().newBuilder()
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
.dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST })
|
||||
.connectionPool(connectionPool)
|
||||
.cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES))
|
||||
|
||||
if (sslSocketFactory != null && trustManager != null) {
|
||||
builder.sslSocketFactory(sslSocketFactory, trustManager)
|
||||
}
|
||||
|
||||
return OkHttpImageFetcher(builder.build())
|
||||
}
|
||||
}
|
||||
|
||||
fun reconfigure(
|
||||
sslSocketFactory: SSLSocketFactory?,
|
||||
trustManager: X509TrustManager?,
|
||||
): OkHttpImageFetcher {
|
||||
val builder = client.newBuilder()
|
||||
if (sslSocketFactory != null && trustManager != null) {
|
||||
builder.sslSocketFactory(sslSocketFactory, trustManager)
|
||||
}
|
||||
// Evict idle connections using old SSL config
|
||||
client.connectionPool.evictAll()
|
||||
return OkHttpImageFetcher(builder.build())
|
||||
}
|
||||
|
||||
private fun onComplete() {
|
||||
val shouldClose = synchronized(stateLock) {
|
||||
activeCount--
|
||||
draining && activeCount == 0
|
||||
}
|
||||
if (shouldClose) {
|
||||
client.cache?.close()
|
||||
}
|
||||
}
|
||||
|
||||
override fun fetch(
|
||||
url: String,
|
||||
headers: Map<String, 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)
|
||||
headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun drain() {
|
||||
val shouldClose = synchronized(stateLock) {
|
||||
if (draining) return
|
||||
draining = true
|
||||
activeCount == 0
|
||||
}
|
||||
client.connectionPool.evictAll()
|
||||
if (shouldClose) {
|
||||
client.cache?.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -6,6 +6,9 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- connectivity_plus (0.0.1):
|
||||
- Flutter
|
||||
- cupertino_http (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- device_info_plus (0.0.1):
|
||||
- Flutter
|
||||
- DKImagePickerController/Core (4.3.9):
|
||||
@@ -136,6 +139,7 @@ DEPENDENCIES:
|
||||
- background_downloader (from `.symlinks/plugins/background_downloader/ios`)
|
||||
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- cupertino_http (from `.symlinks/plugins/cupertino_http/darwin`)
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
@@ -184,6 +188,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/bonsoir_darwin/darwin"
|
||||
connectivity_plus:
|
||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||
cupertino_http:
|
||||
:path: ".symlinks/plugins/cupertino_http/darwin"
|
||||
device_info_plus:
|
||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||
file_picker:
|
||||
@@ -249,6 +255,7 @@ SPEC CHECKSUMS:
|
||||
background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad
|
||||
bonsoir_darwin: 29c7ccf356646118844721f36e1de4b61f6cbd0e
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
|
||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -29,9 +29,11 @@
|
||||
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 */; };
|
||||
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 */; };
|
||||
@@ -118,9 +120,11 @@
|
||||
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>"; };
|
||||
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 */
|
||||
@@ -136,15 +140,11 @@
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
B231F52D2E93A44A00BC45D1 /* Core */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = Core;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = Sync;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -158,8 +158,6 @@
|
||||
};
|
||||
FEE084F22EC172080045228E /* Schemas */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = Schemas;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -321,9 +319,11 @@
|
||||
FED3B1952E253E9B0030FD97 /* Images */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
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>";
|
||||
@@ -549,10 +549,14 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||
@@ -581,10 +585,14 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
@@ -600,12 +608,14 @@
|
||||
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 */,
|
||||
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 */,
|
||||
|
||||
@@ -53,7 +53,8 @@ import UIKit
|
||||
|
||||
public static func registerPlugins(with engine: FlutterEngine) {
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -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, 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?]
|
||||
@@ -102,22 +102,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?]
|
||||
@@ -1,19 +1,19 @@
|
||||
import CryptoKit
|
||||
import Accelerate
|
||||
import Flutter
|
||||
import MobileCoreServices
|
||||
import Photos
|
||||
|
||||
class Request {
|
||||
class LocalImageRequest {
|
||||
weak var workItem: DispatchWorkItem?
|
||||
var isCancelled = false
|
||||
let callback: (Result<[String: Int64], any Error>) -> Void
|
||||
let callback: (Result<[String: Int64]?, any Error>) -> Void
|
||||
|
||||
init(callback: @escaping (Result<[String: Int64], any Error>) -> Void) {
|
||||
init(callback: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
|
||||
self.callback = callback
|
||||
}
|
||||
}
|
||||
|
||||
class ThumbnailApiImpl: ThumbnailApi {
|
||||
class LocalImageApiImpl: LocalImageApi {
|
||||
private static let imageManager = PHImageManager.default()
|
||||
private static let fetchOptions = {
|
||||
let fetchOptions = PHFetchOptions()
|
||||
@@ -36,47 +36,39 @@ class ThumbnailApiImpl: ThumbnailApi {
|
||||
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 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 cancelledResult = Result<[String: Int64]?, any Error>.success(nil)
|
||||
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)]))
|
||||
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, completion: @escaping (Result<[String: Int64], any Error>) -> Void) {
|
||||
let request = Request(callback: completion)
|
||||
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
|
||||
let request = LocalImageRequest(callback: completion)
|
||||
let item = DispatchWorkItem {
|
||||
if request.isCancelled {
|
||||
return completion(Self.cancelledResult)
|
||||
@@ -93,7 +85,7 @@ class ThumbnailApiImpl: ThumbnailApi {
|
||||
|
||||
guard let asset = Self.requestAsset(assetId: assetId)
|
||||
else {
|
||||
Self.removeRequest(requestId: requestId)
|
||||
Self.remove(requestId: requestId)
|
||||
completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil)))
|
||||
return
|
||||
}
|
||||
@@ -119,70 +111,54 @@ class ThumbnailApiImpl: ThumbnailApi {
|
||||
|
||||
guard let image = image,
|
||||
let cgImage = image.cgImage else {
|
||||
Self.removeRequest(requestId: requestId)
|
||||
Self.remove(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)))
|
||||
do {
|
||||
let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat)
|
||||
|
||||
if request.isCancelled {
|
||||
buffer.free()
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
request.callback(.success([
|
||||
"pointer": Int64(Int(bitPattern: buffer.data)),
|
||||
"width": Int64(buffer.width),
|
||||
"height": Int64(buffer.height),
|
||||
"rowBytes": Int64(buffer.rowBytes)
|
||||
]))
|
||||
print("Successful response for \(requestId)")
|
||||
Self.remove(requestId: requestId)
|
||||
} catch {
|
||||
Self.remove(requestId: requestId)
|
||||
return completion(.failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", 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.add(requestId: requestId, request: request)
|
||||
Self.processingQueue.async(execute: item)
|
||||
}
|
||||
|
||||
func cancelImageRequest(requestId: Int64) {
|
||||
Self.cancelRequest(requestId: requestId)
|
||||
func cancelRequest(requestId: Int64) {
|
||||
Self.cancel(requestId: requestId)
|
||||
}
|
||||
|
||||
private static func addRequest(requestId: Int64, request: Request) -> Void {
|
||||
private static func add(requestId: Int64, request: LocalImageRequest) -> Void {
|
||||
requestQueue.sync { requests[requestId] = request }
|
||||
}
|
||||
|
||||
private static func removeRequest(requestId: Int64) -> Void {
|
||||
private static func remove(requestId: Int64) -> Void {
|
||||
requestQueue.sync { requests[requestId] = nil }
|
||||
}
|
||||
|
||||
private static func cancelRequest(requestId: Int64) -> Void {
|
||||
private static func cancel(requestId: Int64) -> Void {
|
||||
requestQueue.async {
|
||||
guard let request = requests.removeValue(forKey: requestId) else { return }
|
||||
request.isCancelled = true
|
||||
@@ -203,9 +179,4 @@ class ThumbnailApiImpl: ThumbnailApi {
|
||||
assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) }
|
||||
return asset
|
||||
}
|
||||
|
||||
func waitForActiveState() {
|
||||
Self.activitySemaphore.wait()
|
||||
Self.activitySemaphore.signal()
|
||||
}
|
||||
}
|
||||
118
mobile/ios/Runner/Images/RemoteImages.g.swift
Normal file
@@ -0,0 +1,118 @@
|
||||
// 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, headers: [String: String], requestId: Int64, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
|
||||
func cancelRequest(requestId: Int64) throws
|
||||
}
|
||||
|
||||
/// 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 headersArg = args[1] as! [String: String]
|
||||
let requestIdArg = args[2] as! Int64
|
||||
api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg) { 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
171
mobile/ios/Runner/Images/RemoteImagesImpl.swift
Normal file
@@ -0,0 +1,171 @@
|
||||
import Accelerate
|
||||
import Flutter
|
||||
import MobileCoreServices
|
||||
import Photos
|
||||
|
||||
class RemoteImageRequest {
|
||||
weak var task: URLSessionDataTask?
|
||||
let id: Int64
|
||||
var isCancelled = false
|
||||
var data: CFMutableData?
|
||||
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.data = nil
|
||||
self.completion = completion
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
||||
private static let delegate = RemoteImageApiDelegate()
|
||||
static let session = {
|
||||
let config = URLSessionConfiguration.default
|
||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
|
||||
config.httpAdditionalHeaders = ["User-Agent": "Immich_iOS_\(version)"]
|
||||
let thumbnailPath = FileManager.default.temporaryDirectory.appendingPathComponent("thumbnails", isDirectory: true)
|
||||
try! FileManager.default.createDirectory(at: thumbnailPath, withIntermediateDirectories: true)
|
||||
config.urlCache = URLCache(
|
||||
memoryCapacity: 0,
|
||||
diskCapacity: 1 << 30,
|
||||
directory: thumbnailPath
|
||||
)
|
||||
config.httpMaximumConnectionsPerHost = 16
|
||||
return URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
|
||||
}()
|
||||
|
||||
func requestImage(url: String, headers: [String : String], requestId: Int64, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
|
||||
var urlRequest = URLRequest(url: URL(string: url)!)
|
||||
for (key, value) in headers {
|
||||
urlRequest.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
let task = Self.session.dataTask(with: urlRequest)
|
||||
|
||||
let imageRequest = RemoteImageRequest(id: requestId, task: task, completion: completion)
|
||||
Self.delegate.add(taskId: task.taskIdentifier, request: imageRequest)
|
||||
|
||||
task.resume()
|
||||
}
|
||||
|
||||
func cancelRequest(requestId: Int64) {
|
||||
Self.delegate.cancel(requestId: requestId)
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteImageApiDelegate: NSObject, URLSessionDataDelegate {
|
||||
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated, attributes: .concurrent)
|
||||
private static var rgbaFormat = vImage_CGImageFormat(
|
||||
bitsPerComponent: 8,
|
||||
bitsPerPixel: 32,
|
||||
colorSpace: CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
|
||||
renderingIntent: .perceptual
|
||||
)!
|
||||
private static var requestByTaskId = [Int: RemoteImageRequest]()
|
||||
private static var taskIdByRequestId = [Int64: Int]()
|
||||
private static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil)
|
||||
private static let decodeOptions = [
|
||||
kCGImageSourceShouldCache: false,
|
||||
kCGImageSourceShouldCacheImmediately: true,
|
||||
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||
] as CFDictionary
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession, dataTask: URLSessionDataTask,
|
||||
didReceive response: URLResponse,
|
||||
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
|
||||
) {
|
||||
guard let request = get(taskId: dataTask.taskIdentifier)
|
||||
else {
|
||||
return completionHandler(.cancel)
|
||||
}
|
||||
|
||||
let capacity = max(Int(response.expectedContentLength), 0)
|
||||
request.data = CFDataCreateMutable(nil, capacity)
|
||||
|
||||
completionHandler(.allow)
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask,
|
||||
didReceive data: Data) {
|
||||
guard let request = get(taskId: dataTask.taskIdentifier) else { return }
|
||||
|
||||
data.withUnsafeBytes { bytes in
|
||||
CFDataAppendBytes(request.data, bytes.bindMemory(to: UInt8.self).baseAddress, data.count)
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask,
|
||||
didCompleteWithError error: Error?) {
|
||||
guard let request = get(taskId: task.taskIdentifier) else { return }
|
||||
|
||||
defer { remove(taskId: task.taskIdentifier, requestId: request.id) }
|
||||
|
||||
if let error = error {
|
||||
if request.isCancelled || (error as NSError).code == NSURLErrorCancelled {
|
||||
return request.completion(Self.cancelledResult)
|
||||
}
|
||||
return request.completion(.failure(error))
|
||||
}
|
||||
|
||||
guard let data = request.data else {
|
||||
return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil)))
|
||||
}
|
||||
|
||||
guard let imageSource = CGImageSourceCreateWithData(data, nil),
|
||||
let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, Self.decodeOptions) else {
|
||||
return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil)))
|
||||
}
|
||||
|
||||
if request.isCancelled {
|
||||
return request.completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
do {
|
||||
let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat)
|
||||
|
||||
if request.isCancelled {
|
||||
buffer.free()
|
||||
return request.completion(Self.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)))
|
||||
}
|
||||
}
|
||||
|
||||
@inline(__always) func get(taskId: Int) -> RemoteImageRequest? {
|
||||
Self.requestQueue.sync { Self.requestByTaskId[taskId] }
|
||||
}
|
||||
|
||||
@inline(__always) func add(taskId: Int, request: RemoteImageRequest) -> Void {
|
||||
Self.requestQueue.async(flags: .barrier) {
|
||||
Self.requestByTaskId[taskId] = request
|
||||
Self.taskIdByRequestId[request.id] = taskId
|
||||
}
|
||||
}
|
||||
|
||||
@inline(__always) func remove(taskId: Int, requestId: Int64) -> Void {
|
||||
Self.requestQueue.async(flags: .barrier) {
|
||||
Self.taskIdByRequestId[requestId] = nil
|
||||
Self.requestByTaskId[taskId] = nil
|
||||
}
|
||||
}
|
||||
|
||||
@inline(__always) func cancel(requestId: Int64) -> Void {
|
||||
guard let request: RemoteImageRequest = (Self.requestQueue.sync {
|
||||
guard let taskId = Self.taskIdByRequestId[requestId] else { return nil }
|
||||
return Self.requestByTaskId[taskId]
|
||||
}) else { return }
|
||||
request.isCancelled = true
|
||||
request.task?.cancel()
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:ffi/ffi.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
part 'local_image_request.dart';
|
||||
part 'thumbhash_image_request.dart';
|
||||
@@ -37,27 +34,53 @@ abstract class ImageRequest {
|
||||
|
||||
void _onCancelled();
|
||||
|
||||
Future<ui.FrameInfo?> _fromPlatformImage(Map<String, int> info) async {
|
||||
final address = info['pointer'];
|
||||
if (address == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<ui.FrameInfo?> _fromEncodedPlatformImage(int address, int length) async {
|
||||
final pointer = Pointer<Uint8>.fromAddress(address);
|
||||
if (_isCancelled) {
|
||||
malloc.free(pointer);
|
||||
return null;
|
||||
}
|
||||
|
||||
final int actualWidth;
|
||||
final int actualHeight;
|
||||
final int actualSize;
|
||||
final ui.ImmutableBuffer buffer;
|
||||
try {
|
||||
actualWidth = info['width']!;
|
||||
actualHeight = info['height']!;
|
||||
actualSize = actualWidth * actualHeight * 4;
|
||||
buffer = await ImmutableBuffer.fromUint8List(pointer.asTypedList(actualSize));
|
||||
buffer = await ImmutableBuffer.fromUint8List(pointer.asTypedList(length));
|
||||
} finally {
|
||||
malloc.free(pointer);
|
||||
}
|
||||
|
||||
if (_isCancelled) {
|
||||
buffer.dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
final descriptor = await ui.ImageDescriptor.encoded(buffer);
|
||||
if (_isCancelled) {
|
||||
buffer.dispose();
|
||||
descriptor.dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
final codec = await descriptor.instantiateCodec();
|
||||
if (_isCancelled) {
|
||||
buffer.dispose();
|
||||
descriptor.dispose();
|
||||
codec.dispose();
|
||||
return null;
|
||||
}
|
||||
return await codec.getNextFrame();
|
||||
}
|
||||
|
||||
Future<ui.FrameInfo?> _fromDecodedPlatformImage(int address, int width, int height, int rowBytes) async {
|
||||
final pointer = Pointer<Uint8>.fromAddress(address);
|
||||
if (_isCancelled) {
|
||||
malloc.free(pointer);
|
||||
return null;
|
||||
}
|
||||
|
||||
final size = rowBytes * height;
|
||||
final ui.ImmutableBuffer buffer;
|
||||
try {
|
||||
buffer = await ImmutableBuffer.fromUint8List(pointer.asTypedList(size));
|
||||
} finally {
|
||||
malloc.free(pointer);
|
||||
}
|
||||
@@ -69,8 +92,9 @@ abstract class ImageRequest {
|
||||
|
||||
final descriptor = ui.ImageDescriptor.raw(
|
||||
buffer,
|
||||
width: actualWidth,
|
||||
height: actualHeight,
|
||||
width: width,
|
||||
height: height,
|
||||
rowBytes: rowBytes,
|
||||
pixelFormat: ui.PixelFormat.rgba8888,
|
||||
);
|
||||
final codec = await descriptor.instantiateCodec();
|
||||
|
||||
@@ -16,20 +16,23 @@ class LocalImageRequest extends ImageRequest {
|
||||
return null;
|
||||
}
|
||||
|
||||
final Map<String, int> info = await thumbnailApi.requestImage(
|
||||
final info = await localImageApi.requestImage(
|
||||
localId,
|
||||
requestId: requestId,
|
||||
width: width,
|
||||
height: height,
|
||||
isVideo: assetType == AssetType.video,
|
||||
);
|
||||
if (info == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final frame = await _fromPlatformImage(info);
|
||||
final frame = await _fromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!);
|
||||
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> _onCancelled() {
|
||||
return thumbnailApi.cancelImageRequest(requestId);
|
||||
return localImageApi.cancelRequest(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
part of 'image_request.dart';
|
||||
|
||||
class RemoteImageRequest extends ImageRequest {
|
||||
static final log = Logger('RemoteImageRequest');
|
||||
static final client = HttpClient()..maxConnectionsPerHost = 16;
|
||||
final RemoteCacheManager? cacheManager;
|
||||
final String uri;
|
||||
final Map<String, String> headers;
|
||||
HttpClientRequest? _request;
|
||||
|
||||
RemoteImageRequest({required this.uri, required this.headers, this.cacheManager});
|
||||
RemoteImageRequest({required this.uri, required this.headers});
|
||||
|
||||
@override
|
||||
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
|
||||
@@ -16,164 +12,18 @@ class RemoteImageRequest extends ImageRequest {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: the cache manager makes everything sequential with its DB calls and its operations cannot be cancelled,
|
||||
// so it ends up being a bottleneck. We only prefer fetching from it when it can skip the DB call.
|
||||
final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: true);
|
||||
if (cachedFileImage != null) {
|
||||
return cachedFileImage;
|
||||
}
|
||||
|
||||
try {
|
||||
final buffer = await _downloadImage(uri);
|
||||
if (buffer == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await _decodeBuffer(buffer, decode, scale);
|
||||
} catch (e) {
|
||||
if (_isCancelled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: false);
|
||||
if (cachedFileImage != null) {
|
||||
return cachedFileImage;
|
||||
}
|
||||
|
||||
rethrow;
|
||||
} finally {
|
||||
_request = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<ImmutableBuffer?> _downloadImage(String url) async {
|
||||
if (_isCancelled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final request = _request = await client.getUrl(Uri.parse(url));
|
||||
if (_isCancelled) {
|
||||
request.abort();
|
||||
return _request = null;
|
||||
}
|
||||
|
||||
for (final entry in headers.entries) {
|
||||
request.headers.set(entry.key, entry.value);
|
||||
}
|
||||
final response = await request.close();
|
||||
if (_isCancelled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final cacheManager = this.cacheManager;
|
||||
final streamController = StreamController<List<int>>(sync: true);
|
||||
final Stream<List<int>> stream;
|
||||
unawaited(cacheManager?.putStreamedFile(url, streamController.stream));
|
||||
stream = response.map((chunk) {
|
||||
if (_isCancelled) {
|
||||
throw StateError('Cancelled request');
|
||||
}
|
||||
if (cacheManager != null) {
|
||||
streamController.add(chunk);
|
||||
}
|
||||
return chunk;
|
||||
});
|
||||
|
||||
try {
|
||||
final Uint8List bytes = await _downloadBytes(stream, response.contentLength);
|
||||
unawaited(streamController.close());
|
||||
return await ImmutableBuffer.fromUint8List(bytes);
|
||||
} catch (e) {
|
||||
streamController.addError(e);
|
||||
unawaited(streamController.close());
|
||||
if (_isCancelled) {
|
||||
return null;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Uint8List> _downloadBytes(Stream<List<int>> stream, int length) async {
|
||||
final Uint8List bytes;
|
||||
int offset = 0;
|
||||
if (length > 0) {
|
||||
// Known content length - use pre-allocated buffer
|
||||
bytes = Uint8List(length);
|
||||
await stream.listen((chunk) {
|
||||
bytes.setAll(offset, chunk);
|
||||
offset += chunk.length;
|
||||
}, cancelOnError: true).asFuture();
|
||||
} else {
|
||||
// Unknown content length - collect chunks dynamically
|
||||
final chunks = <List<int>>[];
|
||||
int totalLength = 0;
|
||||
await stream.listen((chunk) {
|
||||
chunks.add(chunk);
|
||||
totalLength += chunk.length;
|
||||
}, cancelOnError: true).asFuture();
|
||||
|
||||
bytes = Uint8List(totalLength);
|
||||
for (final chunk in chunks) {
|
||||
bytes.setAll(offset, chunk);
|
||||
offset += chunk.length;
|
||||
}
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
Future<ImageInfo?> _loadCachedFile(
|
||||
String url,
|
||||
ImageDecoderCallback decode,
|
||||
double scale, {
|
||||
required bool inMemoryOnly,
|
||||
}) async {
|
||||
final cacheManager = this.cacheManager;
|
||||
if (_isCancelled || cacheManager == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final file = await (inMemoryOnly ? cacheManager.getFileFromMemory(url) : cacheManager.getFileFromCache(url));
|
||||
if (_isCancelled || file == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final buffer = await ImmutableBuffer.fromFilePath(file.file.path);
|
||||
return await _decodeBuffer(buffer, decode, scale);
|
||||
} catch (e) {
|
||||
log.severe('Failed to decode cached image', e);
|
||||
unawaited(_evictFile(url));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _evictFile(String url) async {
|
||||
try {
|
||||
await cacheManager?.removeFile(url);
|
||||
} catch (e) {
|
||||
log.severe('Failed to remove cached image', e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<ImageInfo?> _decodeBuffer(ImmutableBuffer buffer, ImageDecoderCallback decode, scale) async {
|
||||
if (_isCancelled) {
|
||||
buffer.dispose();
|
||||
return null;
|
||||
}
|
||||
final codec = await decode(buffer);
|
||||
if (_isCancelled) {
|
||||
buffer.dispose();
|
||||
codec.dispose();
|
||||
return null;
|
||||
}
|
||||
final frame = await codec.getNextFrame();
|
||||
return ImageInfo(image: frame.image, scale: scale);
|
||||
final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId);
|
||||
final frame = switch (info) {
|
||||
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length),
|
||||
{'pointer': int pointer, 'width': int width, 'height': int height, 'rowBytes': int rowBytes} =>
|
||||
await _fromDecodedPlatformImage(pointer, width, height, rowBytes),
|
||||
_ => null,
|
||||
};
|
||||
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
||||
}
|
||||
|
||||
@override
|
||||
void _onCancelled() {
|
||||
_request?.abort();
|
||||
_request = null;
|
||||
Future<void> _onCancelled() {
|
||||
return remoteImageApi.cancelRequest(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ class ThumbhashImageRequest extends ImageRequest {
|
||||
return null;
|
||||
}
|
||||
|
||||
final Map<String, int> info = await thumbnailApi.getThumbhash(thumbhash);
|
||||
final frame = await _fromPlatformImage(info);
|
||||
final Map<String, int> info = await localImageApi.getThumbhash(thumbhash);
|
||||
final frame = await _fromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!);
|
||||
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cronet_http/cronet_http.dart';
|
||||
import 'package:cupertino_http/cupertino_http.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:immich_mobile/utils/user_agent.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
class NetworkRepository {
|
||||
static late Directory _cachePath;
|
||||
static late String _userAgent;
|
||||
static final _clients = <String, http.Client>{};
|
||||
|
||||
static Future<void> init() {
|
||||
return (
|
||||
getTemporaryDirectory().then((cachePath) => _cachePath = cachePath),
|
||||
getUserAgentString().then((userAgent) => _userAgent = userAgent),
|
||||
).wait;
|
||||
}
|
||||
|
||||
static void reset() {
|
||||
Future.microtask(init);
|
||||
for (final client in _clients.values) {
|
||||
client.close();
|
||||
}
|
||||
_clients.clear();
|
||||
}
|
||||
|
||||
const NetworkRepository();
|
||||
|
||||
/// Note: when disk caching is enabled, only one client may use a given directory at a time.
|
||||
/// Different isolates or engines must use different directories.
|
||||
http.Client getHttpClient(
|
||||
String directoryName, {
|
||||
CacheMode cacheMode = CacheMode.memory,
|
||||
int diskCapacity = 0,
|
||||
int maxConnections = 6,
|
||||
int memoryCapacity = 10 << 20,
|
||||
}) {
|
||||
final cachedClient = _clients[directoryName];
|
||||
if (cachedClient != null) {
|
||||
return cachedClient;
|
||||
}
|
||||
|
||||
final directory = Directory('${_cachePath.path}/$directoryName');
|
||||
directory.createSync(recursive: true);
|
||||
if (Platform.isAndroid) {
|
||||
final engine = CronetEngine.build(
|
||||
cacheMode: cacheMode,
|
||||
cacheMaxSize: diskCapacity,
|
||||
storagePath: directory.path,
|
||||
userAgent: _userAgent,
|
||||
);
|
||||
return _clients[directoryName] = CronetClient.fromCronetEngine(engine, closeEngine: true);
|
||||
}
|
||||
|
||||
final config = URLSessionConfiguration.defaultSessionConfiguration()
|
||||
..httpMaximumConnectionsPerHost = maxConnections
|
||||
..cache = URLCache.withCapacity(
|
||||
diskCapacity: diskCapacity,
|
||||
memoryCapacity: memoryCapacity,
|
||||
directory: directory.uri,
|
||||
)
|
||||
..httpAdditionalHeaders = {'User-Agent': _userAgent};
|
||||
return _clients[directoryName] = CupertinoClient.fromSessionConfiguration(config);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
||||
import 'package:immich_mobile/generated/intl_keys.g.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
||||
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
|
||||
@@ -237,6 +238,14 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void reassemble() {
|
||||
if (kDebugMode) {
|
||||
NetworkRepository.reset();
|
||||
}
|
||||
super.reassemble();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final router = ref.watch(appRouterProvider);
|
||||
|
||||
137
mobile/lib/platform/local_image_api.g.dart
generated
Normal file
@@ -0,0 +1,137 @@
|
||||
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
|
||||
|
||||
import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
PlatformException _createConnectionError(String channelName) {
|
||||
return PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'Unable to establish connection on channel: "$channelName".',
|
||||
);
|
||||
}
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
void writeValue(WriteBuffer buffer, Object? value) {
|
||||
if (value is int) {
|
||||
buffer.putUint8(4);
|
||||
buffer.putInt64(value);
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||
switch (type) {
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LocalImageApi {
|
||||
/// Constructor for [LocalImageApi]. The [binaryMessenger] named argument is
|
||||
/// available for dependency injection. If it is left null, the default
|
||||
/// BinaryMessenger will be used which routes to the host platform.
|
||||
LocalImageApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||
|
||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||
|
||||
final String pigeonVar_messageChannelSuffix;
|
||||
|
||||
Future<Map<String, int>?> requestImage(
|
||||
String assetId, {
|
||||
required int requestId,
|
||||
required int width,
|
||||
required int height,
|
||||
required bool isVideo,
|
||||
}) async {
|
||||
final String pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[
|
||||
assetId,
|
||||
requestId,
|
||||
width,
|
||||
height,
|
||||
isVideo,
|
||||
]);
|
||||
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else {
|
||||
return (pigeonVar_replyList[0] as Map<Object?, Object?>?)?.cast<String, int>();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelRequest(int requestId) async {
|
||||
final String pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.LocalImageApi.cancelRequest$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[requestId]);
|
||||
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, int>> getThumbhash(String thumbhash) async {
|
||||
final String pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.LocalImageApi.getThumbhash$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[thumbhash]);
|
||||
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else if (pigeonVar_replyList[0] == null) {
|
||||
throw PlatformException(
|
||||
code: 'null-error',
|
||||
message: 'Host platform returned null value for non-null return value.',
|
||||
);
|
||||
} else {
|
||||
return (pigeonVar_replyList[0] as Map<Object?, Object?>?)!.cast<String, int>();
|
||||
}
|
||||
}
|
||||
}
|
||||
101
mobile/lib/platform/remote_image_api.g.dart
generated
Normal file
@@ -0,0 +1,101 @@
|
||||
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
|
||||
|
||||
import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
PlatformException _createConnectionError(String channelName) {
|
||||
return PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'Unable to establish connection on channel: "$channelName".',
|
||||
);
|
||||
}
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
void writeValue(WriteBuffer buffer, Object? value) {
|
||||
if (value is int) {
|
||||
buffer.putUint8(4);
|
||||
buffer.putInt64(value);
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||
switch (type) {
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteImageApi {
|
||||
/// Constructor for [RemoteImageApi]. The [binaryMessenger] named argument is
|
||||
/// available for dependency injection. If it is left null, the default
|
||||
/// BinaryMessenger will be used which routes to the host platform.
|
||||
RemoteImageApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||
|
||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||
|
||||
final String pigeonVar_messageChannelSuffix;
|
||||
|
||||
Future<Map<String, int>?> requestImage(
|
||||
String url, {
|
||||
required Map<String, String> headers,
|
||||
required int requestId,
|
||||
}) async {
|
||||
final String pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[url, headers, requestId]);
|
||||
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else {
|
||||
return (pigeonVar_replyList[0] as Map<Object?, Object?>?)?.cast<String, int>();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelRequest(int requestId) async {
|
||||
final String pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.RemoteImageApi.cancelRequest$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[requestId]);
|
||||
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,12 @@ import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
|
||||
with CancellableImageProviderMixin<RemoteThumbProvider> {
|
||||
static final cacheManager = RemoteThumbnailCacheManager();
|
||||
final String assetId;
|
||||
final String thumbhash;
|
||||
|
||||
@@ -41,7 +39,6 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
|
||||
final request = this.request = RemoteImageRequest(
|
||||
uri: getThumbnailUrlForRemoteId(key.assetId, thumbhash: key.thumbhash),
|
||||
headers: ApiService.getRequestHeaders(),
|
||||
cacheManager: cacheManager,
|
||||
);
|
||||
return loadRequest(request, decode);
|
||||
}
|
||||
@@ -62,7 +59,6 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
|
||||
|
||||
class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider>
|
||||
with CancellableImageProviderMixin<RemoteFullImageProvider> {
|
||||
static final cacheManager = RemoteThumbnailCacheManager();
|
||||
final String assetId;
|
||||
final String thumbhash;
|
||||
|
||||
@@ -98,7 +94,6 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
||||
final request = this.request = RemoteImageRequest(
|
||||
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
|
||||
headers: headers,
|
||||
cacheManager: cacheManager,
|
||||
);
|
||||
yield* loadRequest(request, decode);
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@ import 'dart:ui';
|
||||
|
||||
const double kTimelineHeaderExtent = 80.0;
|
||||
const Size kTimelineFixedTileExtent = Size.square(256);
|
||||
const Size kThumbnailResolution = Size.square(320); // TODO: make the resolution vary based on actual tile size
|
||||
const double kTimelineSpacing = 2.0;
|
||||
const int kTimelineColumnCount = 3;
|
||||
|
||||
const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300);
|
||||
const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800);
|
||||
|
||||
const Size kThumbnailResolution = Size.square(320); // TODO: make the resolution vary based on actual tile size
|
||||
const kThumbnailDiskCacheSize = 1024 << 20; // 1GiB
|
||||
|
||||
@@ -1,148 +1,25 @@
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
// ignore: implementation_imports
|
||||
import 'package:flutter_cache_manager/src/cache_store.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
abstract class RemoteCacheManager extends CacheManager {
|
||||
static final _log = Logger('RemoteCacheManager');
|
||||
|
||||
RemoteCacheManager.custom(super.config, CacheStore store)
|
||||
// Unfortunately, CacheStore is not a public API
|
||||
// ignore: invalid_use_of_visible_for_testing_member
|
||||
: super.custom(cacheStore: store);
|
||||
|
||||
Future<void> putStreamedFile(
|
||||
String url,
|
||||
Stream<List<int>> source, {
|
||||
String? key,
|
||||
String? eTag,
|
||||
Duration maxAge = const Duration(days: 30),
|
||||
String fileExtension = 'file',
|
||||
});
|
||||
|
||||
// Unlike `putFileStream`, this method handles request cancellation,
|
||||
// does not make a (slow) DB call checking if the file is already cached,
|
||||
// does not synchronously check if a file exists,
|
||||
// and deletes the file on cancellation without making these checks again.
|
||||
Future<void> putStreamedFileToStore(
|
||||
CacheStore store,
|
||||
String url,
|
||||
Stream<List<int>> source, {
|
||||
String? key,
|
||||
String? eTag,
|
||||
Duration maxAge = const Duration(days: 30),
|
||||
String fileExtension = 'file',
|
||||
}) async {
|
||||
final path = '${const Uuid().v1()}.$fileExtension';
|
||||
final file = await store.fileSystem.createFile(path);
|
||||
final sink = file.openWrite();
|
||||
try {
|
||||
await source.listen(sink.add, cancelOnError: true).asFuture();
|
||||
} catch (e) {
|
||||
try {
|
||||
await sink.close();
|
||||
await file.delete();
|
||||
} catch (e) {
|
||||
_log.severe('Failed to delete incomplete cache file: $e');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sink.flush();
|
||||
await sink.close();
|
||||
} catch (e) {
|
||||
try {
|
||||
await file.delete();
|
||||
} catch (e) {
|
||||
_log.severe('Failed to delete incomplete cache file: $e');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final cacheObject = CacheObject(
|
||||
url,
|
||||
key: key,
|
||||
relativePath: path,
|
||||
validTill: DateTime.now().add(maxAge),
|
||||
eTag: eTag,
|
||||
);
|
||||
try {
|
||||
await store.putFile(cacheObject);
|
||||
} catch (e) {
|
||||
try {
|
||||
await file.delete();
|
||||
} catch (e) {
|
||||
_log.severe('Failed to delete untracked cache file: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteImageCacheManager extends RemoteCacheManager {
|
||||
class RemoteImageCacheManager extends CacheManager {
|
||||
static const key = 'remoteImageCacheKey';
|
||||
static final RemoteImageCacheManager _instance = RemoteImageCacheManager._();
|
||||
static final _config = Config(key, maxNrOfCacheObjects: 500, stalePeriod: const Duration(days: 30));
|
||||
static final _store = CacheStore(_config);
|
||||
|
||||
factory RemoteImageCacheManager() {
|
||||
return _instance;
|
||||
}
|
||||
|
||||
RemoteImageCacheManager._() : super.custom(_config, _store);
|
||||
|
||||
@override
|
||||
Future<void> putStreamedFile(
|
||||
String url,
|
||||
Stream<List<int>> source, {
|
||||
String? key,
|
||||
String? eTag,
|
||||
Duration maxAge = const Duration(days: 30),
|
||||
String fileExtension = 'file',
|
||||
}) {
|
||||
return putStreamedFileToStore(
|
||||
_store,
|
||||
url,
|
||||
source,
|
||||
key: key,
|
||||
eTag: eTag,
|
||||
maxAge: maxAge,
|
||||
fileExtension: fileExtension,
|
||||
);
|
||||
}
|
||||
RemoteImageCacheManager._() : super(_config);
|
||||
}
|
||||
|
||||
/// The cache manager for full size images [ImmichRemoteImageProvider]
|
||||
class RemoteThumbnailCacheManager extends RemoteCacheManager {
|
||||
class RemoteThumbnailCacheManager extends CacheManager {
|
||||
static const key = 'remoteThumbnailCacheKey';
|
||||
static final RemoteThumbnailCacheManager _instance = RemoteThumbnailCacheManager._();
|
||||
static final _config = Config(key, maxNrOfCacheObjects: 5000, stalePeriod: const Duration(days: 30));
|
||||
static final _store = CacheStore(_config);
|
||||
|
||||
factory RemoteThumbnailCacheManager() {
|
||||
return _instance;
|
||||
}
|
||||
|
||||
RemoteThumbnailCacheManager._() : super.custom(_config, _store);
|
||||
|
||||
@override
|
||||
Future<void> putStreamedFile(
|
||||
String url,
|
||||
Stream<List<int>> source, {
|
||||
String? key,
|
||||
String? eTag,
|
||||
Duration maxAge = const Duration(days: 30),
|
||||
String fileExtension = 'file',
|
||||
}) {
|
||||
return putStreamedFileToStore(
|
||||
_store,
|
||||
url,
|
||||
source,
|
||||
key: key,
|
||||
eTag: eTag,
|
||||
maxAge: maxAge,
|
||||
fileExtension: fileExtension,
|
||||
);
|
||||
}
|
||||
RemoteThumbnailCacheManager._() : super(_config);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import 'package:immich_mobile/platform/background_worker_api.g.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/platform/thumbnail_api.g.dart';
|
||||
import 'package:immich_mobile/platform/local_image_api.g.dart';
|
||||
import 'package:immich_mobile/platform/remote_image_api.g.dart';
|
||||
|
||||
final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
|
||||
|
||||
@@ -16,4 +17,6 @@ final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
|
||||
|
||||
final connectivityApiProvider = Provider<ConnectivityApi>((_) => ConnectivityApi());
|
||||
|
||||
final thumbnailApi = ThumbnailApi();
|
||||
final localImageApi = LocalImageApi();
|
||||
|
||||
final remoteImageApi = RemoteImageApi();
|
||||
|
||||
@@ -21,6 +21,7 @@ import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
@@ -106,5 +107,7 @@ abstract final class Bootstrap {
|
||||
storeRepository: storeRepo,
|
||||
shouldBuffer: shouldBufferLogs,
|
||||
);
|
||||
|
||||
await NetworkRepository.init();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,7 +326,7 @@ class _BackupDelaySliderState extends ConsumerState<_BackupDelaySlider> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 24.0, top: 8.0),
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 8.0),
|
||||
child: Text(
|
||||
'backup_controller_page_background_delay'.tr(
|
||||
namedArgs: {'duration': formatBackupDelaySliderValue(currentValue)},
|
||||
|
||||
@@ -7,12 +7,14 @@ build:
|
||||
|
||||
pigeon:
|
||||
dart run pigeon --input pigeon/native_sync_api.dart
|
||||
dart run pigeon --input pigeon/thumbnail_api.dart
|
||||
dart run pigeon --input pigeon/local_image_api.dart
|
||||
dart run pigeon --input pigeon/remote_image_api.dart
|
||||
dart run pigeon --input pigeon/background_worker_api.dart
|
||||
dart run pigeon --input pigeon/background_worker_lock_api.dart
|
||||
dart run pigeon --input pigeon/connectivity_api.dart
|
||||
dart format lib/platform/native_sync_api.g.dart
|
||||
dart format lib/platform/thumbnail_api.g.dart
|
||||
dart format lib/platform/local_image_api.g.dart
|
||||
dart format lib/platform/remote_image_api.g.dart
|
||||
dart format lib/platform/background_worker_api.g.dart
|
||||
dart format lib/platform/background_worker_lock_api.g.dart
|
||||
dart format lib/platform/connectivity_api.g.dart
|
||||
|
||||
@@ -34,10 +34,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.16.0"
|
||||
version: "1.17.0"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
|
||||
@@ -2,20 +2,20 @@ import 'package:pigeon/pigeon.dart';
|
||||
|
||||
@ConfigurePigeon(
|
||||
PigeonOptions(
|
||||
dartOut: 'lib/platform/thumbnail_api.g.dart',
|
||||
swiftOut: 'ios/Runner/Images/Thumbnails.g.swift',
|
||||
dartOut: 'lib/platform/local_image_api.g.dart',
|
||||
swiftOut: 'ios/Runner/Images/LocalImages.g.swift',
|
||||
swiftOptions: SwiftOptions(includeErrorClass: false),
|
||||
kotlinOut:
|
||||
'android/app/src/main/kotlin/app/alextran/immich/images/Thumbnails.g.kt',
|
||||
'android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt',
|
||||
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.images'),
|
||||
dartOptions: DartOptions(),
|
||||
dartPackageName: 'immich_mobile',
|
||||
),
|
||||
)
|
||||
@HostApi()
|
||||
abstract class ThumbnailApi {
|
||||
abstract class LocalImageApi {
|
||||
@async
|
||||
Map<String, int> requestImage(
|
||||
Map<String, int>? requestImage(
|
||||
String assetId, {
|
||||
required int requestId,
|
||||
required int width,
|
||||
@@ -23,7 +23,7 @@ abstract class ThumbnailApi {
|
||||
required bool isVideo,
|
||||
});
|
||||
|
||||
void cancelImageRequest(int requestId);
|
||||
void cancelRequest(int requestId);
|
||||
|
||||
@async
|
||||
Map<String, int> getThumbhash(String thumbhash);
|
||||
25
mobile/pigeon/remote_image_api.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:pigeon/pigeon.dart';
|
||||
|
||||
@ConfigurePigeon(
|
||||
PigeonOptions(
|
||||
dartOut: 'lib/platform/remote_image_api.g.dart',
|
||||
swiftOut: 'ios/Runner/Images/RemoteImages.g.swift',
|
||||
swiftOptions: SwiftOptions(includeErrorClass: false),
|
||||
kotlinOut:
|
||||
'android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt',
|
||||
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.images', includeErrorClass: false),
|
||||
dartOptions: DartOptions(),
|
||||
dartPackageName: 'immich_mobile',
|
||||
),
|
||||
)
|
||||
@HostApi()
|
||||
abstract class RemoteImageApi {
|
||||
@async
|
||||
Map<String, int>? requestImage(
|
||||
String url, {
|
||||
required Map<String, String> headers,
|
||||
required int requestId,
|
||||
});
|
||||
|
||||
void cancelRequest(int requestId);
|
||||
}
|
||||
@@ -297,6 +297,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_assets:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: ae0db647e668cbb295a3527f0938e4039e004c80099dce2f964102373f5ce0b5
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.19.10"
|
||||
code_builder:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -337,6 +345,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
cronet_http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cronet_http
|
||||
sha256: "1fff7f26ac0c4cda97fe2a9aa082494baee4775f167c27ba45f6c8e88571e3ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.7.0"
|
||||
crop_image:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -369,6 +385,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
cupertino_http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cupertino_http
|
||||
sha256: "82cbec60c90bf785a047a9525688b6dacac444e177e1d5a5876963d3c50369e8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
custom_lint:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -888,6 +912,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.1"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "5410b9f4f6c9f01e8ff0eb81c9801ea13a3c3d39f8f0b1613cda08e27eab3c18"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.20.5"
|
||||
hooks_riverpod:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -936,6 +968,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
http_profile:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_profile
|
||||
sha256: "7e679e355b09aaee2ab5010915c932cce3f2d1c11c3b2dc177891687014ffa78"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.0"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1077,6 +1117,14 @@ packages:
|
||||
url: "https://github.com/immich-app/isar"
|
||||
source: git
|
||||
version: "3.1.8"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni
|
||||
sha256: "8706a77e94c76fe9ec9315e18949cc9479cc03af97085ca9c1077b61323ea12d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.15.2"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1217,10 +1265,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.16.0"
|
||||
version: "1.17.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1237,6 +1285,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: f8872ea6c7a50ce08db9ae280ca2b8efdd973157ce462826c82f3c3051d154ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.2"
|
||||
native_video_player:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1270,6 +1326,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.0"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "55eb67ede1002d9771b3f9264d2c9d30bc364f0267bc1c6cc0883280d5f0c7cb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.2.2"
|
||||
octo_image:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1902,10 +1966,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.6"
|
||||
version: "0.7.7"
|
||||
thumbhash:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -2195,5 +2259,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.9.0 <4.0.0"
|
||||
dart: ">=3.10.0 <4.0.0"
|
||||
flutter: ">=3.35.7"
|
||||
|
||||
@@ -86,6 +86,8 @@ dependencies:
|
||||
uuid: ^4.5.1
|
||||
wakelock_plus: ^1.3.0
|
||||
worker_manager: ^7.2.7
|
||||
cronet_http: ^1.7.0
|
||||
cupertino_http: ^2.4.0
|
||||
|
||||
dev_dependencies:
|
||||
auto_route_generator: ^9.0.0
|
||||
|
||||
@@ -34,7 +34,8 @@ type SendFile = Parameters<Response['sendFile']>;
|
||||
type SendFileOptions = SendFile[1];
|
||||
|
||||
const cacheControlHeaders: Record<CacheControl, string | null> = {
|
||||
[CacheControl.PrivateWithCache]: 'private, max-age=86400, no-transform',
|
||||
[CacheControl.PrivateWithCache]:
|
||||
'private, max-age=86400, no-transform, stale-while-revalidate=2592000, stale-if-error=2592000',
|
||||
[CacheControl.PrivateWithoutCache]: 'private, no-cache, no-transform',
|
||||
[CacheControl.None]: null, // falsy value to prevent adding Cache-Control header
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
type UpdateLibraryDto,
|
||||
} from '@immich/sdk';
|
||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||
import { mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js';
|
||||
import { mdiInformationOutline, mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js';
|
||||
import type { MessageFormatter } from 'svelte-i18n';
|
||||
|
||||
export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResponseDto[]) => {
|
||||
@@ -45,6 +45,13 @@ export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResp
|
||||
};
|
||||
|
||||
export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponseDto) => {
|
||||
const Detail: ActionItem = {
|
||||
icon: mdiInformationOutline,
|
||||
type: $t('command'),
|
||||
title: $t('details'),
|
||||
onAction: () => goto(Route.viewLibrary(library)),
|
||||
};
|
||||
|
||||
const Edit: ActionItem = {
|
||||
icon: mdiPencilOutline,
|
||||
type: $t('command'),
|
||||
@@ -84,7 +91,7 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
|
||||
shortcuts: { shift: true, key: 'r' },
|
||||
};
|
||||
|
||||
return { Edit, Delete, AddFolder, AddExclusionPattern, Scan };
|
||||
return { Detail, Edit, Delete, AddFolder, AddExclusionPattern, Scan };
|
||||
};
|
||||
|
||||
export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryResponseDto, folder: string) => {
|
||||
|
||||
@@ -4,14 +4,16 @@
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { getLibrariesActions } from '$lib/services/library.service';
|
||||
import { getLibrariesActions, getLibraryActions } from '$lib/services/library.service';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
||||
import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
Button,
|
||||
CommandPaletteDefaultProvider,
|
||||
Container,
|
||||
ContextMenuButton,
|
||||
Link,
|
||||
MenuItemType,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
@@ -58,13 +60,18 @@
|
||||
|
||||
const { Create, ScanAll } = $derived(getLibrariesActions($t, libraries));
|
||||
|
||||
const getActionsForLibrary = (library: LibraryResponseDto) => {
|
||||
const { Detail, Scan, Edit, Delete } = getLibraryActions($t, library);
|
||||
return [Detail, Scan, Edit, MenuItemType.Divider, Delete];
|
||||
};
|
||||
|
||||
const classes = {
|
||||
column1: 'w-4/12',
|
||||
column2: 'w-4/12',
|
||||
column3: 'w-2/12',
|
||||
column4: 'w-2/12',
|
||||
column5: 'w-2/12',
|
||||
column6: 'w-2/12',
|
||||
column3: 'w-1/12',
|
||||
column4: 'w-1/12',
|
||||
column5: 'w-1/12',
|
||||
column6: 'w-1/12 flex justify-end',
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -89,14 +96,19 @@
|
||||
{#each libraries as library (library.id + library.name)}
|
||||
{@const { photos, usage, videos } = statistics[library.id]}
|
||||
{@const [diskUsage, diskUsageUnit] = getBytesWithUnit(usage, 0)}
|
||||
{@const owner = owners[library.id]}
|
||||
<TableRow>
|
||||
<TableCell class={classes.column1}>{library.name}</TableCell>
|
||||
<TableCell class={classes.column2}>{owners[library.id].name}</TableCell>
|
||||
<TableCell class={classes.column1}>
|
||||
<Link href={Route.viewLibrary(library)}>{library.name}</Link>
|
||||
</TableCell>
|
||||
<TableCell class={classes.column2}>
|
||||
<Link href={Route.viewUser(owner)}>{owner.name}</Link>
|
||||
</TableCell>
|
||||
<TableCell class={classes.column3}>{photos.toLocaleString($locale)}</TableCell>
|
||||
<TableCell class={classes.column4}>{videos.toLocaleString($locale)}</TableCell>
|
||||
<TableCell class={classes.column5}>{diskUsage} {diskUsageUnit}</TableCell>
|
||||
<TableCell class={classes.column6}>
|
||||
<Button size="small" href={Route.viewLibrary(library)}>{$t('view')}</Button>
|
||||
<ContextMenuButton color="primary" aria-label={$t('open')} items={getActionsForLibrary(library)} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
|
||||