Compare commits

...

37 Commits

Author SHA1 Message Date
mertalev
2498f315d3 support videos on ios 2026-02-06 22:04:53 -05:00
mertalev
03afe8fb3e handle onProgress 2026-02-05 15:58:29 -05:00
mertalev
e7b7fe8ed8 formatting 2026-02-05 15:40:46 -05:00
mertalev
78a834408c fix proguard 2026-02-05 15:24:10 -05:00
mertalev
378a068a29 redundant logging 2026-02-05 15:24:10 -05:00
mertalev
e89d174bd0 websocket integration
platform-side headers

update comment

consistent platform check

tweak websocket handling

support streaming
2026-02-05 15:24:10 -05:00
mertalev
3228dcb70b use shared client in dart
fix android
2026-02-05 15:24:10 -05:00
Min Idzelis
092ebe01a5 fix: queue assets missing fullsize files for thumbnail regeneration (#25794)
* fix: queue assets missing fullsize files for thumbnail regeneration

* refactor: query

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-02-05 19:31:13 +00:00
Brandon Wees
37e5968a7a fix: face and edit handling (#25738)
* fix: handle edits when creating face
2026-02-05 19:29:46 +00:00
Dion de Koning
cfc5ed5997 fix: timezone issue in tests (#25937)
* Fixed an issue where time tests fail in some timezones

* Revert previous fix and add TZ env variable to fix the issue

* Revert other changes and align TZ fix between server and web

* Revert package lock file

---------

Co-authored-by: Dion de Koning <dion@DionK01.local>
2026-02-05 19:24:23 +00:00
Romo
1b3c0e4f65 fix: image download complete notification shows an extra {file_name} template tag (#25936)
* [fix] Image download complete notification shows an extra {file_name} template tag

fixes https://github.com/immich-app/immich/issues/25690
added

```dart
final FileName = 'file_name'.t( args: {'file_name': '{filename}', }, );
```

* chore: fix formatting

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-02-05 19:19:33 +00:00
Mert
fd49d7d566 chore(mobile): skip macro validation (#25744)
* use podfile

* update deps
2026-02-05 14:16:21 -05:00
cmdPromptCritical
ad9f3cfa05 fix(mobile): cancel share download when dialog is dismissed (#25466)
* fix(mobile): cancel share download when dialog is dismissed

* refactor: centralize temporary file cleanup logic

* refactor: replace `CancellationToken` with `Completer<void>` for asset sharing cancellation

---------

Co-authored-by: cmdpromptcritical <cmdpromptcritical@github.com>
2026-02-05 19:08:35 +00:00
Dane
9d8efe2685 fix(docs): add missing --json-output arg to CLI example (#25870) 2026-02-05 14:00:27 -05:00
Paul Makles
ed4d9abdae fix(server): use provided database username for restore & ensure name is not mangled (#25679)
* fix(server): use provided database name/username for restore & ensure name is not mangled

fixes #25633

Signed-off-by: izzy <me@insrt.uk>

* chore: add db switch back but with comments

Signed-off-by: izzy <me@insrt.uk>

* refactor: no need to restore database since it's not technically possible
chore: late fallback for username in parameter builder

Signed-off-by: izzy <me@insrt.uk>

* chore: type fix

Signed-off-by: izzy <me@insrt.uk>

* chore: re-use the username we just pulled out

---------

Signed-off-by: izzy <me@insrt.uk>
2026-02-05 13:59:05 -05:00
Vahant Sharma
ac9f6921cc fix(server): add missing history metadata to getAuthStatus endpoint (#25927)
* fix(server): add missing history metadata to getAuthStatus endpoint

* chore: regenerate openapi specs
2026-02-05 18:56:44 +00:00
shenlong
f0da875e37 fix: allow clear text traffic on android (#25933)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-02-06 00:15:33 +05:30
Peter Ombodi
b0e1a425b3 fix(mobile): jump to previous asset when last asset is deleted (#25563)
* fix(mobile): fix wrong index, update pageController

* fix(mobile): refactor code

---------

Co-authored-by: Peter Ombodi <peter.ombodi@gmail.com>
Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
2026-02-05 18:19:40 +00:00
Yaros
7211d80e5f docs: update mobile setup to use mise (#25847)
docs: update mobile setup to mise
2026-02-05 12:55:38 -05:00
shenlong
92c79a7122 chore: increase cache_size and use memory temp store (#25930)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-02-05 11:55:07 -06:00
renovate[bot]
7580521a76 chore(deps): update grafana/grafana docker tag to v12.3.2 (#25840)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-05 12:52:07 -05:00
shenlong
2dd3a764ae fix: timezone in timeline bucketing (#25894)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-02-05 11:47:16 -06:00
Thomas
a42c08ed84 fix(mobile): reset asset index on timeline refresh (#25729)
The current asset changes when the timeline refreshes, which can be
quite jarring. Assets are tracked by their index, and that index becomes
stale when the timeline refreshes. This can be resolved by updating the
index of asset based on a unique identifier (like the hero tag).
2026-02-05 11:46:38 -06:00
Aditya Gaurav
3c77c724c5 fix(web): Ensure profile picture is cropped to 1:1 ratio (#25892)
* fix(web): Ensure profile picture is cropped to 1:1 ratio

Fixes #20097

The profile picture was being captured from the PhotoViewer element
which could have non-square dimensions based on the original image.

Changed to capture from the crop container element which has the
aspect-square class, ensuring the output is always 1:1 ratio.

* fix: remove trailing whitespace to pass prettier check

---------

Co-authored-by: Aditya Gaurav <aditya-ai-architect@users.noreply.github.com>
2026-02-05 11:45:06 -06:00
Yaros
84b2979485 docs: non-code contributing (datasets) (#25850)
docs: datasets
2026-02-05 11:44:35 -06:00
Paul Makles
e9c2ca008a docs: update manual backup/restore to match the automatic process (#25924)
* docs: update manual backup/restore to match automatic process

closes #25772

* docs: update DB_NAME to DB_DATABASE_NAME
2026-02-05 12:43:33 -05:00
Daniel Dietzler
9c098109e0 fix: time zone upserts (#25889) 2026-02-05 12:43:03 -05:00
Mert
27a2808470 fix(mobile): mtls on native clients (#25802)
* handle mtls on ios

* update android impl

* ui improvements

* dead code

* no need to store data separately

* improve concurrency

* dead code

* add migration

* remove unused dependency

* trust user-installed certs

* removed print statement

* fix ios

* improve android styling

* outdated comments

* update lock file

* handle translation

* fix prompt cancellation

* fix video playback

* Apply suggestion from @shenlong-tanwen

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>

* Apply suggestion from @shenlong-tanwen

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>

* formatting

---------

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
2026-02-05 17:42:53 +00:00
Alex
0a8a65a45e fix: file name search label (#25916) 2026-02-05 17:24:58 +00:00
shenlong
2b6055e830 chore: do not process cloud ids during sync & hash (#25914)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-02-05 11:23:50 -06:00
Mert
ba2dfa7df6 refactor(mobile): consolidate image requests (#25743)
remote url image provider

remove cached_network_image

formatting

linting

remove thumb provider

formatting
2026-02-05 12:16:42 -05:00
Jason Rasmussen
237ea3aedd chore: update oauth documentation (#25907)
* chore: prefer lowercase for non i18n labels

* chore: update documentation
2026-02-05 09:00:00 -05:00
Michel Heusschen
810e9254f3 fix: preserve hidden people state across pagination (#25886)
* fix: preserve hidden people state across pagination

* track overrides instead

* use event instead of bind:people

* update test
2026-02-05 08:51:30 -05:00
Michel Heusschen
57e0835b46 fix: ensure theme stays in sync with @immich/ui (#25922) 2026-02-05 08:36:20 -05:00
Michel Heusschen
e97030a7ae fix: make switch labels properly clickable (#25898) 2026-02-05 12:09:27 +01:00
Michel Heusschen
fdf06a91cc fix: improve asset editor exit handling (#25917) 2026-02-05 12:01:54 +01:00
Michel Heusschen
732303661b fix: allow null tagIds in search dto (#25920) 2026-02-05 11:52:35 +01:00
155 changed files with 4049 additions and 1844 deletions

View File

@@ -23,9 +23,21 @@ We generally discourage PRs entirely generated by an LLM. For any part generated
From time to time, we put a feature freeze on parts of the codebase. For us, this means we won't accept most PRs that make changes in that area. Exempted from this are simple bug fixes that require only minor changes. We will close feature PRs that target a feature-frozen area, even if that feature is highly requested and you put a lot of work into it. Please keep that in mind, and if you're ever uncertain if a PR would be accepted, reach out to us first (e.g., in the aforementioned `#contributing` channel). We hate to throw away work. Currently, we have feature freezes on:
* Sharing/Asset ownership
* (External) libraries
- Sharing/Asset ownership
- (External) libraries
## Non-code contributions
If you want to contribute to Immich but you don't feel comfortable programming in our tech stack, there are other ways you can help the team. All our translations are done through [Weblate](https://hosted.weblate.org/projects/immich). These rely entirely on the community; if you speak a language that isn't fully translated yet, submitting translations there is greatly appreciated! If you like helping others, answering Q&A discussions here on GitHub and replying to people on our Discord is also always appreciated.
If you want to contribute to Immich but you don't feel comfortable programming in our tech stack, there are other ways you can help the team.
### Translations
All our translations are done through [Weblate](https://hosted.weblate.org/projects/immich). These rely entirely on the community; if you speak a language that isn't fully translated yet, submitting translations there is greatly appreciated!
### Datasets
Help us improve our [Immich Datasets](https://datasets.immich.app) by submitting photos and videos taken from a variety of devices, including smartphones, DSLRs, and action cameras, as well as photos with unique features, such as panoramas, burst photos, and photo spheres. These datasets will be publically available for anyone to use, do not submit private/sensitive photos.
### Community support
If you like helping others, answering Q&A discussions here on GitHub and replying to people on our Discord is also always appreciated.

View File

@@ -97,7 +97,7 @@ services:
command: ['./run.sh', '-disable-reporting']
ports:
- 3000:3000
image: grafana/grafana:12.3.1-ubuntu@sha256:d57f1365197aec34c4d80869d8ca45bb7787c7663904950dab214dfb40c1c2fd
image: grafana/grafana:12.3.2-ubuntu@sha256:6cca4b429a1dc0d37d401dee54825c12d40056c3c6f3f56e3f0d6318ce77749b
volumes:
- grafana-data:/var/lib/grafana

View File

@@ -140,7 +140,8 @@ For advanced users or automated recovery scenarios, you can restore a database b
```bash title='Backup'
# Replace <DB_USERNAME> with the database username - usually postgres unless you have changed it.
docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=<DB_USERNAME> | gzip > "/path/to/backup/dump.sql.gz"
# Replace <DB_DATABASE_NAME> with the database name - usually immich unless you have changed it.
docker exec -t immich_postgres pg_dump --clean --if-exists --dbname=<DB_DATABASE_NAME> --username=<DB_USERNAME> | gzip > "/path/to/backup/dump.sql.gz"
```
```bash title='Restore'
@@ -153,9 +154,10 @@ docker start immich_postgres # Start Postgres server
sleep 10 # Wait for Postgres server to start up
# Check the database user if you deviated from the default
# Replace <DB_USERNAME> with the database username - usually postgres unless you have changed it.
# Replace <DB_DATABASE_NAME> with the database name - usually immich unless you have changed it.
gunzip --stdout "/path/to/backup/dump.sql.gz" \
| sed "s/SELECT pg_catalog.set_config('search_path', '', false);/SELECT pg_catalog.set_config('search_path', 'public, pg_catalog', true);/g" \
| docker exec -i immich_postgres psql --dbname=postgres --username=<DB_USERNAME> # Restore Backup
| docker exec -i immich_postgres psql --dbname=<DB_DATABASE_NAME> --username=<DB_USERNAME> --single-transaction --set ON_ERROR_STOP=on # Restore Backup
docker compose up -d # Start remainder of Immich apps
```
@@ -164,7 +166,8 @@ docker compose up -d # Start remainder of Immich apps
```powershell title='Backup'
# Replace <DB_USERNAME> with the database username - usually postgres unless you have changed it.
[System.IO.File]::WriteAllLines("C:\absolute\path\to\backup\dump.sql", (docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=<DB_USERNAME>))
# Replace <DB_DATABASE_NAME> with the database name - usually immich unless you have changed it.
[System.IO.File]::WriteAllLines("C:\absolute\path\to\backup\dump.sql", (docker exec -t immich_postgres pg_dump --clean --if-exists --dbname=<DB_DATABASE_NAME> --username=<DB_USERNAME>))
```
```powershell title='Restore'
@@ -179,8 +182,9 @@ sleep 10 # Wait for Postgres server to
docker exec -it immich_postgres bash # Enter the Docker shell and run the following command
# If your backup ends in `.gz`, replace `cat` with `gunzip --stdout`
# Replace <DB_USERNAME> with the database username - usually postgres unless you have changed it.
# Replace <DB_DATABASE_NAME> with the database name - usually immich unless you have changed it.
cat "/dump.sql" | sed "s/SELECT pg_catalog.set_config('search_path', '', false);/SELECT pg_catalog.set_config('search_path', 'public, pg_catalog', true);/g" | psql --dbname=postgres --username=<DB_USERNAME>
cat "/dump.sql" | sed "s/SELECT pg_catalog.set_config('search_path', '', false);/SELECT pg_catalog.set_config('search_path', 'public, pg_catalog', true);/g" | psql --dbname=<DB_DATABASE_NAME> --username=<DB_USERNAME> --single-transaction --set ON_ERROR_STOP=on
exit # Exit the Docker shell
docker compose up -d # Start remainder of Immich apps
```
@@ -188,6 +192,10 @@ docker compose up -d # Start remainder of Immich ap
</TabItem>
</Tabs>
:::warning
The backup and restore process changed in v2.5.0, if you have a backup created with an older version of Immich, use the documentation version selector to find manual restore instructions for your backup.
:::
:::note
For the database restore to proceed properly, it requires a completely fresh install (i.e., the Immich server has never run since creating the Docker containers). If the Immich app has run, you may encounter Postgres conflicts (relation already exists, violated foreign key constraints, etc.). In this case, delete the `DB_DATA_LOCATION` folder to reset the database.
:::
@@ -196,6 +204,10 @@ For the database restore to proceed properly, it requires a completely fresh ins
Some deployment methods make it difficult to start the database without also starting the server. In these cases, set the environment variable `DB_SKIP_MIGRATIONS=true` before starting the services. This prevents the server from running migrations that interfere with the restore process. Remove this variable and restart services after the database is restored.
:::
:::tip
The provided restore process ensures your database is never in a broken state by committing all changes in one transaction. This may be undesirable behaviour in some circumstances, you can disable it by removing `--single-transaction --set ON_ERROR_STOP=on` from the command.
:::
## Filesystem
Immich stores two types of content in the filesystem: (a) original, unmodified assets (photos and videos), and (b) generated content. We recommend backing up the entire contents of `UPLOAD_LOCATION`, but only the original content is critical, which is stored in the following folders:

View File

@@ -56,11 +56,13 @@ Once you have a new OAuth client application configured, Immich can be configure
| Setting | Type | Default | Description |
| ---------------------------------------------------- | ------- | -------------------- | ----------------------------------------------------------------------------------- |
| Enabled | boolean | false | Enable/disable OAuth |
| Issuer URL | URL | (required) | Required. Self-discovery URL for client (from previous step) |
| Client ID | string | (required) | Required. Client ID (from previous step) |
| Client Secret | string | (required) | Required. Client Secret (previous step) |
| Scope | string | openid email profile | Full list of scopes to send with the request (space delimited) |
| Signing Algorithm | string | RS256 | The algorithm used to sign the id token (examples: RS256, HS256) |
| `issuer_url` | URL | (required) | Required. Self-discovery URL for client (from previous step) |
| `client_id` | string | (required) | Required. Client ID (from previous step) |
| `client_secret` | string | (required) | Required. Client Secret (previous step) |
| `scope` | string | openid email profile | Full list of scopes to send with the request (space delimited) |
| `id_token_signed_response_alg` | string | RS256 | The algorithm used to sign the id token (examples: RS256, HS256) |
| `userinfo_signed_response_alg` | string | none | The algorithm used to sign the userinfo response (examples: RS256, HS256) |
| Request timeout | string | 30,000 (30 seconds) | Number of milliseconds to wait for http requests to complete before giving up |
| Storage Label Claim | string | preferred_username | Claim mapping for the user's storage label**¹** |
| Role Claim | string | immich_role | Claim mapping for the user's role. (should return "user" or "admin")**¹** |
| Storage Quota Claim | string | immich_quota | Claim mapping for the user's storage**¹** |

View File

@@ -90,10 +90,13 @@ To see local changes to `@immich/ui` in Immich, do the following:
#### Setup
1. Setup Flutter toolchain using FVM.
2. Run `flutter pub get` to install the dependencies.
3. Run `make translation` to generate the translation file.
4. Run `fvm flutter run` to start the app.
1. [Install mise](https://mise.jdx.dev/installing-mise.html).
2. Change to the immich (root) directory and trust the mise config with `mise trust`.
3. Install tools with mise: `mise install`.
4. Change to the `mobile/` directory.
5. Run `flutter pub get` to install the dependencies.
6. Run `make translation` to generate the translation file.
7. Run `flutter run` to start the app.
#### Translation

View File

@@ -183,7 +183,7 @@ For example to get a list of files that would be uploaded for further
processing:
```bash
immich upload --dry-run . | tail -n +6 | jq .newFiles[]
immich upload --dry-run --json-output . | tail -n +6 | jq .newFiles[]
```
### Obtain the API Key

View File

@@ -473,6 +473,7 @@ describe('/asset', () => {
id: user1Assets[0].id,
exifInfo: expect.objectContaining({
dateTimeOriginal: '2023-11-20T01:11:00+00:00',
timeZone: 'UTC-7',
}),
});
expect(status).toEqual(200);

View File

@@ -782,6 +782,8 @@
"client_cert_import": "Import",
"client_cert_import_success_msg": "Client certificate is imported",
"client_cert_invalid_msg": "Invalid certificate file or wrong password",
"client_cert_password_message": "Enter the password for this certificate",
"client_cert_password_title": "Certificate Password",
"client_cert_remove_msg": "Client certificate is removed",
"client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate import/removal is available only before login",
"client_cert_title": "SSL client certificate [EXPERIMENTAL]",
@@ -1195,8 +1197,9 @@
"features": "Features",
"features_in_development": "Features in Development",
"features_setting_description": "Manage the app features",
"file_name": "File name: {file_name}",
"file_name_or_extension": "File name or extension",
"file_name_text": "File name",
"file_name_with_value": "File name: {file_name}",
"file_size": "File size",
"filename": "Filename",
"filetype": "Filetype",

View File

@@ -81,6 +81,7 @@ android {
release {
signingConfig signingConfigs.release
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
namespace 'app.alextran.immich'
@@ -131,6 +132,7 @@ dependencies {
implementation "androidx.compose.ui:ui-tooling:$compose_version"
implementation "androidx.compose.material3:material3:1.2.1"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.2"
implementation "com.google.android.material:material:1.12.0"
}
// This is uncommented in F-Droid build script

View File

@@ -36,4 +36,12 @@
##---------------End: proguard configuration for Gson ----------
# Keep all widget model classes and their fields for Gson
-keep class app.alextran.immich.widget.model.** { *; }
-keep class app.alextran.immich.widget.model.** { *; }
##---------------Begin: proguard configuration for ok_http JNI ----------
# The ok_http Dart plugin accesses OkHttp and Okio classes via JNI
# string-based reflection (JClass.forName), which R8 cannot trace.
-keep class okhttp3.** { *; }
-keep class okio.** { *; }
-keep class com.example.ok_http.** { *; }
##---------------End: proguard configuration for ok_http JNI ----------

View File

@@ -27,7 +27,8 @@
<application android:label="Immich" android:name=".ImmichApp" android:usesCleartextTraffic="true"
android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="true"
android:largeHeap="true" android:enableOnBackInvokedCallback="false" android:allowBackup="false">
android:largeHeap="true" android:enableOnBackInvokedCallback="false" android:allowBackup="false"
android:networkSecurityConfig="@xml/network_security_config">
<profileable android:shell="true" />

View File

@@ -36,3 +36,17 @@ Java_app_alextran_immich_NativeBuffer_copy(
memcpy((void *) destAddress, (char *) src + offset, length);
}
}
/**
* Creates a JNI global reference to the given object and returns its address.
* The caller is responsible for deleting the global reference when it's no longer needed.
*/
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_NativeBuffer_createGlobalRef(JNIEnv *env, jobject clazz, jobject obj) {
if (obj == NULL) {
return 0;
}
jobject globalRef = (*env)->NewGlobalRef(env, obj);
return (jlong) globalRef;
}

View File

@@ -1,153 +0,0 @@
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
import io.flutter.plugin.common.MethodChannel
import java.io.ByteArrayInputStream
import java.net.InetSocketAddress
import java.net.Socket
import java.security.KeyStore
import java.security.cert.X509Certificate
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.KeyManager
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLSession
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509ExtendedTrustManager
/**
* Android plugin for Dart `HttpSSLOptions`
*/
class HttpSSLOptionsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private var methodChannel: MethodChannel? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
}
private fun onAttachedToEngine(ctx: Context, messenger: BinaryMessenger) {
methodChannel = MethodChannel(messenger, "immich/httpSSLOptions")
methodChannel?.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
onDetachedFromEngine()
}
private fun onDetachedFromEngine() {
methodChannel?.setMethodCallHandler(null)
methodChannel = null
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
try {
when (call.method) {
"apply" -> {
val args = call.arguments<ArrayList<*>>()!!
val allowSelfSigned = args[0] as Boolean
val serverHost = args[1] as? String
val clientCertHash = (args[2] as? ByteArray)
var tm: Array<TrustManager>? = null
if (allowSelfSigned) {
tm = arrayOf(AllowSelfSignedTrustManager(serverHost))
}
var km: Array<KeyManager>? = null
if (clientCertHash != null) {
val cert = ByteArrayInputStream(clientCertHash)
val password = (args[3] as String).toCharArray()
val keyStore = KeyStore.getInstance("PKCS12")
keyStore.load(cert, password)
val keyManagerFactory =
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
keyManagerFactory.init(keyStore, null)
km = keyManagerFactory.keyManagers
}
// 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)
HttpsURLConnection.setDefaultHostnameVerifier(AllowSelfSignedHostnameVerifier(args[1] as? String))
result.success(true)
}
else -> result.notImplemented()
}
} catch (e: Throwable) {
result.error("error", e.message, null)
}
}
@SuppressLint("CustomX509TrustManager")
class AllowSelfSignedTrustManager(private val serverHost: String?) : X509ExtendedTrustManager() {
private val defaultTrustManager: X509ExtendedTrustManager = getDefaultTrustManager()
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) =
defaultTrustManager.checkClientTrusted(chain, authType)
override fun checkClientTrusted(
chain: Array<out X509Certificate>?, authType: String?, socket: Socket?
) = defaultTrustManager.checkClientTrusted(chain, authType, socket)
override fun checkClientTrusted(
chain: Array<out X509Certificate>?, authType: String?, engine: SSLEngine?
) = defaultTrustManager.checkClientTrusted(chain, authType, engine)
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
if (serverHost == null) return
defaultTrustManager.checkServerTrusted(chain, authType)
}
override fun checkServerTrusted(
chain: Array<out X509Certificate>?, authType: String?, socket: Socket?
) {
if (serverHost == null) return
val socketAddress = socket?.remoteSocketAddress
if (socketAddress is InetSocketAddress && socketAddress.hostName == serverHost) return
defaultTrustManager.checkServerTrusted(chain, authType, socket)
}
override fun checkServerTrusted(
chain: Array<out X509Certificate>?, authType: String?, engine: SSLEngine?
) {
if (serverHost == null || engine?.peerHost == serverHost) return
defaultTrustManager.checkServerTrusted(chain, authType, engine)
}
override fun getAcceptedIssuers(): Array<X509Certificate> = defaultTrustManager.acceptedIssuers
private fun getDefaultTrustManager(): X509ExtendedTrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
return factory.trustManagers.filterIsInstance<X509ExtendedTrustManager>().first()
}
}
class AllowSelfSignedHostnameVerifier(private val serverHost: String?) : HostnameVerifier {
companion object {
private val _defaultHostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
}
override fun verify(hostname: String?, session: SSLSession?): Boolean {
if (serverHost == null || hostname == serverHost) {
return true
} else {
return _defaultHostnameVerifier.verify(hostname, session)
}
}
}
}

View File

@@ -9,7 +9,9 @@ import app.alextran.immich.background.BackgroundWorkerFgHostApi
import app.alextran.immich.background.BackgroundWorkerLockApi
import app.alextran.immich.connectivity.ConnectivityApi
import app.alextran.immich.connectivity.ConnectivityApiImpl
import app.alextran.immich.core.HttpClientManager
import app.alextran.immich.core.ImmichPlugin
import app.alextran.immich.core.NetworkApiPlugin
import app.alextran.immich.images.LocalImageApi
import app.alextran.immich.images.LocalImagesImpl
import app.alextran.immich.images.RemoteImageApi
@@ -28,6 +30,9 @@ class MainActivity : FlutterFragmentActivity() {
companion object {
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
HttpClientManager.initialize(ctx)
flutterEngine.plugins.add(NetworkApiPlugin())
val messenger = flutterEngine.dartExecutor.binaryMessenger
val backgroundEngineLockImpl = BackgroundEngineLock(ctx)
BackgroundWorkerLockApi.setUp(messenger, backgroundEngineLockImpl)
@@ -45,7 +50,6 @@ class MainActivity : FlutterFragmentActivity() {
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
flutterEngine.plugins.add(BackgroundServicePlugin())
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
flutterEngine.plugins.add(backgroundEngineLockImpl)
flutterEngine.plugins.add(nativeSyncApiImpl)
}

View File

@@ -23,6 +23,9 @@ object NativeBuffer {
@JvmStatic
external fun copy(buffer: ByteBuffer, destAddress: Long, offset: Int, length: Int)
@JvmStatic
external fun createGlobalRef(obj: Any): Long
}
class NativeByteBuffer(initialCapacity: Int) {

View File

@@ -0,0 +1,159 @@
package app.alextran.immich.core
import android.content.Context
import app.alextran.immich.BuildConfig
import okhttp3.Cache
import okhttp3.ConnectionPool
import okhttp3.Dispatcher
import okhttp3.Headers
import okhttp3.OkHttpClient
import java.io.ByteArrayInputStream
import java.io.File
import java.net.Socket
import java.security.KeyStore
import java.security.Principal
import java.security.PrivateKey
import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509KeyManager
import javax.net.ssl.X509TrustManager
const val CERT_ALIAS = "client_cert"
const val USER_AGENT = "Immich_Android_${BuildConfig.VERSION_NAME}"
/**
* Manages a shared OkHttpClient with SSL configuration support.
*/
object HttpClientManager {
private const val CACHE_SIZE_BYTES = 100L * 1024 * 1024 // 100MiB
private const val KEEP_ALIVE_CONNECTIONS = 10
private const val KEEP_ALIVE_DURATION_MINUTES = 5L
private const val MAX_REQUESTS_PER_HOST = 64
private var initialized = false
private val clientChangedListeners = mutableListOf<() -> Unit>()
private lateinit var client: OkHttpClient
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
var headers: Headers = Headers.headersOf("User-Agent", USER_AGENT)
val isMtls: Boolean get() = keyStore.containsAlias(CERT_ALIAS)
fun initialize(context: Context) {
if (initialized) return
synchronized(this) {
if (initialized) return
val cacheDir = File(File(context.cacheDir, "okhttp"), "api")
client = build(cacheDir)
initialized = true
}
}
fun setKeyEntry(clientData: ByteArray, password: CharArray) {
synchronized(this) {
val wasMtls = isMtls
val tmpKeyStore = KeyStore.getInstance("PKCS12").apply {
ByteArrayInputStream(clientData).use { stream -> load(stream, password) }
}
val tmpAlias = tmpKeyStore.aliases().asSequence().firstOrNull { tmpKeyStore.isKeyEntry(it) }
?: throw IllegalArgumentException("No private key found in PKCS12")
val key = tmpKeyStore.getKey(tmpAlias, password)
val chain = tmpKeyStore.getCertificateChain(tmpAlias)
if (wasMtls) {
keyStore.deleteEntry(CERT_ALIAS)
}
keyStore.setKeyEntry(CERT_ALIAS, key, null, chain)
if (wasMtls != isMtls) {
clientChangedListeners.forEach { it() }
}
}
}
fun deleteKeyEntry() {
synchronized(this) {
if (!isMtls) {
return
}
keyStore.deleteEntry(CERT_ALIAS)
clientChangedListeners.forEach { it() }
}
}
@JvmStatic
fun getClient(): OkHttpClient {
return client
}
fun addClientChangedListener(listener: () -> Unit) {
synchronized(this) { clientChangedListeners.add(listener) }
}
fun setRequestHeaders(headerMap: Map<String, String>) {
val builder = Headers.Builder()
headerMap.forEach { (key, value) -> builder.add(key, value) }
headers = builder.build()
}
private fun build(cacheDir: File): OkHttpClient {
val connectionPool = ConnectionPool(
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
keepAliveDuration = KEEP_ALIVE_DURATION_MINUTES,
timeUnit = TimeUnit.MINUTES
)
val managerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
managerFactory.init(null as KeyStore?)
val trustManager = managerFactory.trustManagers.filterIsInstance<X509TrustManager>().first()
val sslContext = SSLContext.getInstance("TLS")
.apply { init(arrayOf(DynamicKeyManager()), arrayOf(trustManager), null) }
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
return OkHttpClient.Builder()
.addInterceptor {
val builder = it.request().newBuilder()
headers.forEach { (key, value) -> builder.addHeader(key, value) }
it.proceed(builder.build())
}
.connectionPool(connectionPool)
.dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST })
.cache(Cache(cacheDir.apply { mkdirs() }, CACHE_SIZE_BYTES))
.sslSocketFactory(sslContext.socketFactory, trustManager)
.build()
}
// Reads from the key store rather than taking a snapshot at initialization time
private class DynamicKeyManager : X509KeyManager {
override fun getClientAliases(keyType: String, issuers: Array<Principal>?): Array<String>? =
if (isMtls) arrayOf(CERT_ALIAS) else null
override fun chooseClientAlias(
keyTypes: Array<String>,
issuers: Array<Principal>?,
socket: Socket?
): String? =
if (isMtls) CERT_ALIAS else null
override fun getCertificateChain(alias: String): Array<X509Certificate>? =
keyStore.getCertificateChain(alias)?.map { it as X509Certificate }?.toTypedArray()
override fun getPrivateKey(alias: String): PrivateKey? =
keyStore.getKey(alias, null) as? PrivateKey
override fun getServerAliases(keyType: String, issuers: Array<Principal>?): Array<String>? =
null
override fun chooseServerAlias(
keyType: String,
issuers: Array<Principal>?,
socket: Socket?
): String? = null
}
}

View File

@@ -0,0 +1,351 @@
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
package app.alextran.immich.core
import android.util.Log
import io.flutter.plugin.common.BasicMessageChannel
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MessageCodec
import io.flutter.plugin.common.StandardMethodCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private object NetworkPigeonUtils {
fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
}
fun wrapError(exception: Throwable): List<Any?> {
return if (exception is FlutterError) {
listOf(
exception.code,
exception.message,
exception.details
)
} else {
listOf(
exception.javaClass.simpleName,
exception.toString(),
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
)
}
}
fun deepEquals(a: Any?, b: Any?): Boolean {
if (a is ByteArray && b is ByteArray) {
return a.contentEquals(b)
}
if (a is IntArray && b is IntArray) {
return a.contentEquals(b)
}
if (a is LongArray && b is LongArray) {
return a.contentEquals(b)
}
if (a is DoubleArray && b is DoubleArray) {
return a.contentEquals(b)
}
if (a is Array<*> && b is Array<*>) {
return a.size == b.size &&
a.indices.all{ deepEquals(a[it], b[it]) }
}
if (a is List<*> && b is List<*>) {
return a.size == b.size &&
a.indices.all{ deepEquals(a[it], b[it]) }
}
if (a is Map<*, *> && b is Map<*, *>) {
return a.size == b.size && a.all {
(b as Map<Any?, Any?>).containsKey(it.key) &&
deepEquals(it.value, b[it.key])
}
}
return a == b
}
}
/**
* Error class for passing custom error details to Flutter via a thrown PlatformException.
* @property code The error code.
* @property message The error message.
* @property details The error details. Must be a datatype supported by the api codec.
*/
class FlutterError (
val code: String,
override val message: String? = null,
val details: Any? = null
) : Throwable()
/** Generated class from Pigeon that represents data sent in messages. */
data class ClientCertData (
val data: ByteArray,
val password: String
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): ClientCertData {
val data = pigeonVar_list[0] as ByteArray
val password = pigeonVar_list[1] as String
return ClientCertData(data, password)
}
}
fun toList(): List<Any?> {
return listOf(
data,
password,
)
}
override fun equals(other: Any?): Boolean {
if (other !is ClientCertData) {
return false
}
if (this === other) {
return true
}
return NetworkPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
/** Generated class from Pigeon that represents data sent in messages. */
data class ClientCertPrompt (
val title: String,
val message: String,
val cancel: String,
val confirm: String
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): ClientCertPrompt {
val title = pigeonVar_list[0] as String
val message = pigeonVar_list[1] as String
val cancel = pigeonVar_list[2] as String
val confirm = pigeonVar_list[3] as String
return ClientCertPrompt(title, message, cancel, confirm)
}
}
fun toList(): List<Any?> {
return listOf(
title,
message,
cancel,
confirm,
)
}
override fun equals(other: Any?): Boolean {
if (other !is ClientCertPrompt) {
return false
}
if (this === other) {
return true
}
return NetworkPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
/** Generated class from Pigeon that represents data sent in messages. */
data class WebSocketTaskResult (
val taskPointer: Long,
val taskProtocol: String? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): WebSocketTaskResult {
val taskPointer = pigeonVar_list[0] as Long
val taskProtocol = pigeonVar_list[1] as String?
return WebSocketTaskResult(taskPointer, taskProtocol)
}
}
fun toList(): List<Any?> {
return listOf(
taskPointer,
taskProtocol,
)
}
override fun equals(other: Any?): Boolean {
if (other !is WebSocketTaskResult) {
return false
}
if (this === other) {
return true
}
return NetworkPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
private open class NetworkPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
129.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
ClientCertData.fromList(it)
}
}
130.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
ClientCertPrompt.fromList(it)
}
}
131.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
WebSocketTaskResult.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) {
is ClientCertData -> {
stream.write(129)
writeValue(stream, value.toList())
}
is ClientCertPrompt -> {
stream.write(130)
writeValue(stream, value.toList())
}
is WebSocketTaskResult -> {
stream.write(131)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface NetworkApi {
fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit)
fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<ClientCertData>) -> Unit)
fun removeCertificate(callback: (Result<Unit>) -> Unit)
fun getClientPointer(): Long
/** iOS only - creates a WebSocket task and waits for connection to be established. */
fun createWebSocketTask(url: String, protocols: List<String>?, callback: (Result<WebSocketTaskResult>) -> Unit)
fun setRequestHeaders(headers: Map<String, String>)
companion object {
/** The codec used by NetworkApi. */
val codec: MessageCodec<Any?> by lazy {
NetworkPigeonCodec()
}
/** Sets up an instance of `NetworkApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: NetworkApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.addCertificate$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val clientDataArg = args[0] as ClientCertData
api.addCertificate(clientDataArg) { result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(NetworkPigeonUtils.wrapError(error))
} else {
reply.reply(NetworkPigeonUtils.wrapResult(null))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.selectCertificate$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val promptTextArg = args[0] as ClientCertPrompt
api.selectCertificate(promptTextArg) { result: Result<ClientCertData> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(NetworkPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(NetworkPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.removeCertificate$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.removeCertificate{ result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(NetworkPigeonUtils.wrapError(error))
} else {
reply.reply(NetworkPigeonUtils.wrapResult(null))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.getClientPointer())
} catch (exception: Throwable) {
NetworkPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.createWebSocketTask$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val urlArg = args[0] as String
val protocolsArg = args[1] as List<String>?
api.createWebSocketTask(urlArg, protocolsArg) { result: Result<WebSocketTaskResult> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(NetworkPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(NetworkPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val headersArg = args[0] as Map<String, String>
val wrapped: List<Any?> = try {
api.setRequestHeaders(headersArg)
listOf(null)
} catch (exception: Throwable) {
NetworkPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}

View File

@@ -0,0 +1,176 @@
package app.alextran.immich.core
import android.app.Activity
import android.content.Context
import android.net.Uri
import android.os.OperationCanceledException
import android.text.InputType
import android.view.ContextThemeWrapper
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import app.alextran.immich.NativeBuffer
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
class NetworkApiPlugin : FlutterPlugin, ActivityAware {
private var networkApi: NetworkApiImpl? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
networkApi = NetworkApiImpl(binding.applicationContext)
NetworkApi.setUp(binding.binaryMessenger, networkApi)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
NetworkApi.setUp(binding.binaryMessenger, null)
networkApi = null
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
networkApi?.onAttachedToActivity(binding)
}
override fun onDetachedFromActivityForConfigChanges() {
networkApi?.onDetachedFromActivityForConfigChanges()
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
networkApi?.onReattachedToActivityForConfigChanges(binding)
}
override fun onDetachedFromActivity() {
networkApi?.onDetachedFromActivity()
}
}
private class NetworkApiImpl(private val context: Context) : NetworkApi {
private var activity: Activity? = null
private var pendingCallback: ((Result<ClientCertData>) -> Unit)? = null
private var filePicker: ActivityResultLauncher<Array<String>>? = null
private var promptText: ClientCertPrompt? = null
fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
(binding.activity as? ComponentActivity)?.let { componentActivity ->
filePicker = componentActivity.registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri -> uri?.let { handlePickedFile(it) } ?: pendingCallback?.invoke(Result.failure(OperationCanceledException())) }
}
}
fun onDetachedFromActivityForConfigChanges() {
activity = null
}
fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity
}
fun onDetachedFromActivity() {
activity = null
}
override fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit) {
try {
HttpClientManager.setKeyEntry(clientData.data, clientData.password.toCharArray())
callback(Result.success(Unit))
} catch (e: Exception) {
callback(Result.failure(e))
}
}
override fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<ClientCertData>) -> Unit) {
val picker = filePicker ?: return callback(Result.failure(IllegalStateException("No activity")))
pendingCallback = callback
this.promptText = promptText
picker.launch(arrayOf("application/x-pkcs12", "application/x-pem-file"))
}
override fun removeCertificate(callback: (Result<Unit>) -> Unit) {
HttpClientManager.deleteKeyEntry()
callback(Result.success(Unit))
}
override fun getClientPointer(): Long {
val client = HttpClientManager.getClient()
return NativeBuffer.createGlobalRef(client)
}
// only used on iOS
override fun createWebSocketTask(
url: String,
protocols: List<String>?,
callback: (Result<WebSocketTaskResult>) -> Unit
) {}
override fun setRequestHeaders(headers: Map<String, String>) {
HttpClientManager.setRequestHeaders(headers)
}
private fun handlePickedFile(uri: Uri) {
val callback = pendingCallback ?: return
pendingCallback = null
try {
val data = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: throw IllegalStateException("Could not read file")
val activity = activity ?: throw IllegalStateException("No activity")
promptForPassword(activity) { password ->
promptText = null
if (password == null) {
callback(Result.failure(OperationCanceledException()))
return@promptForPassword
}
try {
HttpClientManager.setKeyEntry(data, password.toCharArray())
callback(Result.success(ClientCertData(data, password)))
} catch (e: Exception) {
callback(Result.failure(e))
}
}
} catch (e: Exception) {
callback(Result.failure(e))
}
}
private fun promptForPassword(activity: Activity, callback: (String?) -> Unit) {
val themedContext = ContextThemeWrapper(activity, com.google.android.material.R.style.Theme_Material3_DayNight_Dialog)
val density = activity.resources.displayMetrics.density
val horizontalPadding = (24 * density).toInt()
val textInputLayout = TextInputLayout(themedContext).apply {
hint = "Password"
endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply {
setMargins(horizontalPadding, 0, horizontalPadding, 0)
}
}
val editText = TextInputEditText(textInputLayout.context).apply {
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
}
textInputLayout.addView(editText)
val container = FrameLayout(themedContext).apply { addView(textInputLayout) }
val text = promptText!!
MaterialAlertDialogBuilder(themedContext)
.setTitle(text.title)
.setMessage(text.message)
.setView(container)
.setPositiveButton(text.confirm) { _, _ -> callback(editText.text.toString()) }
.setNegativeButton(text.cancel) { _, _ -> callback(null) }
.setOnCancelListener { callback(null) }
.show()
}
}

View File

@@ -1,73 +0,0 @@
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()
}
}

View File

@@ -47,7 +47,7 @@ private open class RemoteImagesPigeonCodec : StandardMessageCodec() {
/** 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 requestImage(url: String, requestId: Long, callback: (Result<Map<String, Long>?>) -> Unit)
fun cancelRequest(requestId: Long)
fun clearCache(callback: (Result<Long>) -> Unit)
@@ -66,9 +66,8 @@ interface RemoteImageApi {
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 requestIdArg = args[1] as Long
api.requestImage(urlArg, requestIdArg) { result: Result<Map<String, Long>?> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(RemoteImagesPigeonUtils.wrapError(error))

View File

@@ -3,17 +3,15 @@ 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 app.alextran.immich.core.HttpClientManager
import app.alextran.immich.core.USER_AGENT
import kotlinx.coroutines.*
import okhttp3.Cache
import okhttp3.Call
import okhttp3.Callback
import okhttp3.ConnectionPool
import okhttp3.Dispatcher
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
@@ -32,15 +30,8 @@ import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
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)
@@ -58,7 +49,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
override fun requestImage(
url: String,
headers: Map<String, String>,
requestId: Long,
callback: (Result<Map<String, Long>?>) -> Unit
) {
@@ -67,7 +57,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
ImageFetcherManager.fetch(
url,
headers,
signal,
onSuccess = { buffer ->
requestMap.remove(requestId)
@@ -121,19 +110,18 @@ private object ImageFetcherManager {
appContext = context.applicationContext
cacheDir = context.cacheDir
fetcher = build()
SSLConfig.addListener(::invalidate)
HttpClientManager.addClientChangedListener(::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)
fetcher.fetch(url, signal, onSuccess, onFailure)
}
fun clearCache(onCleared: (Result<Long>) -> Unit) {
@@ -143,18 +131,14 @@ private object ImageFetcherManager {
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)
return if (HttpClientManager.isMtls) {
OkHttpImageFetcher.create(cacheDir)
} else {
CronetImageFetcher(appContext, cacheDir)
}
@@ -164,7 +148,6 @@ private object ImageFetcherManager {
private sealed interface ImageFetcher {
fun fetch(
url: String,
headers: Map<String, String>,
signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit,
@@ -191,7 +174,6 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
override fun fetch(
url: String,
headers: Map<String, String>,
signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit,
@@ -206,7 +188,7 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor)
headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
HttpClientManager.headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
val request = requestBuilder.build()
signal.setOnCancelListener(request::cancel)
request.start()
@@ -380,51 +362,17 @@ private class OkHttpImageFetcher private constructor(
private var draining = false
companion object {
fun create(
cacheDir: File,
sslSocketFactory: SSLSocketFactory?,
trustManager: X509TrustManager?,
): OkHttpImageFetcher {
fun create(cacheDir: File): 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)
val client = HttpClientManager.getClient().newBuilder()
.cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES))
.build()
if (sslSocketFactory != null && trustManager != null) {
builder.sslSocketFactory(sslSocketFactory, trustManager)
}
return OkHttpImageFetcher(builder.build())
return OkHttpImageFetcher(client)
}
}
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--
@@ -437,7 +385,6 @@ private class OkHttpImageFetcher private constructor(
override fun fetch(
url: String,
headers: Map<String, String>,
signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit,
@@ -450,7 +397,6 @@ private class OkHttpImageFetcher private constructor(
}
val requestBuilder = Request.Builder().url(url)
headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
val call = client.newCall(requestBuilder.build())
signal.setOnCancelListener(call::cancel)
@@ -512,7 +458,6 @@ private class OkHttpImageFetcher private constructor(
draining = true
activeCount == 0
}
client.connectionPool.evictAll()
if (shouldClose) {
client.cache?.close()
}

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>

View File

@@ -121,4 +121,6 @@ post_install do |installer|
end
# End of the permission_handler configuration
end
system("defaults write com.apple.dt.Xcode IDESkipPackagePluginFingerprintValidatation -bool YES")
system("defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES")
end

View File

@@ -11,40 +11,6 @@ PODS:
- FlutterMacOS
- device_info_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.9):
- DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource
- DKImagePickerController/ImageDataManager (4.3.9)
- DKImagePickerController/PhotoGallery (4.3.9):
- DKImagePickerController/Core
- DKPhotoGallery
- DKImagePickerController/Resource (4.3.9)
- DKPhotoGallery (0.0.19):
- DKPhotoGallery/Core (= 0.0.19)
- DKPhotoGallery/Model (= 0.0.19)
- DKPhotoGallery/Preview (= 0.0.19)
- DKPhotoGallery/Resource (= 0.0.19)
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Core (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Preview
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Model (0.0.19):
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Preview (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Resource
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Resource (0.0.19):
- SDWebImage
- SwiftyGif
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- Flutter (1.0.0)
- flutter_local_notifications (0.0.1):
- Flutter
@@ -93,9 +59,6 @@ PODS:
- Flutter
- FlutterMacOS
- SAMKeychain (1.5.3)
- SDWebImage (5.21.0):
- SDWebImage/Core (= 5.21.0)
- SDWebImage/Core (5.21.0)
- share_handler_ios (0.0.14):
- Flutter
- share_handler_ios/share_handler_ios_models (= 0.0.14)
@@ -131,7 +94,6 @@ PODS:
- sqlite3/fts5
- sqlite3/perf-threadsafe
- sqlite3/rtree
- SwiftyGif (5.4.5)
- url_launcher_ios (0.0.1):
- Flutter
- wakelock_plus (0.0.1):
@@ -143,7 +105,6 @@ DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- cupertino_http (from `.symlinks/plugins/cupertino_http/darwin`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- Flutter (from `Flutter`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
@@ -176,13 +137,9 @@ DEPENDENCIES:
SPEC REPOS:
trunk:
- DKImagePickerController
- DKPhotoGallery
- MapLibre
- SAMKeychain
- SDWebImage
- sqlite3
- SwiftyGif
EXTERNAL SOURCES:
background_downloader:
@@ -195,8 +152,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/cupertino_http/darwin"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
Flutter:
:path: Flutter
flutter_local_notifications:
@@ -262,9 +217,6 @@ SPEC CHECKSUMS:
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
@@ -288,7 +240,6 @@ SPEC CHECKSUMS:
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
photo_manager: 1d80ae07a89a67dfbcae95953a1e5a24af7c3e62
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868
share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb
share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
@@ -296,10 +247,9 @@ SPEC CHECKSUMS:
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983
sqlite3_flutter_libs: f8fc13346870e73fe35ebf6dbb997fbcd156b241
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
PODFILE CHECKSUM: 7ce312f2beab01395db96f6969d90a447279cf45
PODFILE CHECKSUM: 938abbae4114b9c2140c550a2a0d8f7c674f5dfe
COCOAPODS: 1.16.2

View File

@@ -33,6 +33,7 @@
FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F22F1197D8006016CB /* RemoteImages.g.swift */; };
FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F52F11980E006016CB /* LocalImagesImpl.swift */; };
FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */; };
FE5FE4AE2F30FBC000A71243 /* ImageProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */; };
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; };
FEE084F82EC172460045228E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084F72EC172460045228E /* SQLiteData */; };
FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */; };
@@ -124,6 +125,7 @@
FE5499F22F1197D8006016CB /* RemoteImages.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImages.g.swift; sourceTree = "<group>"; };
FE5499F52F11980E006016CB /* LocalImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalImagesImpl.swift; sourceTree = "<group>"; };
FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImagesImpl.swift; sourceTree = "<group>"; };
FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessing.swift; sourceTree = "<group>"; };
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -325,6 +327,7 @@
FED3B1952E253E9B0030FD97 /* Images */ = {
isa = PBXGroup;
children = (
FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */,
FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */,
FE5499F52F11980E006016CB /* LocalImagesImpl.swift */,
FE5499F12F1197D8006016CB /* LocalImages.g.swift */,
@@ -609,6 +612,7 @@
FE5499F32F1197D8006016CB /* LocalImages.g.swift in Sources */,
FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */,
FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */,
FE5FE4AE2F30FBC000A71243 /* ImageProcessing.swift in Sources */,
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */,
FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */,
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,

View File

@@ -15,8 +15,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/groue/GRDB.swift",
"state" : {
"revision" : "18497b68fdbb3a09528d260a0a0e1e7e61c8c53d",
"version" : "7.8.0"
"revision" : "aa0079aeb82a4bf00324561a40bffe68c6fe1c26",
"version" : "7.9.0"
}
},
{
@@ -24,17 +24,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/sqlite-data",
"state" : {
"revision" : "b66b894b9a5710f1072c8eb6448a7edfc2d743d9",
"version" : "1.3.0"
}
},
{
"identity" : "swift-case-paths",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-case-paths",
"state" : {
"revision" : "6989976265be3f8d2b5802c722f9ba168e227c71",
"version" : "1.7.2"
"revision" : "05704b563ecb7f0bd7e49b6f360a6383a3e53e7d",
"version" : "1.5.1"
}
},
{
@@ -132,8 +123,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-structured-queries",
"state" : {
"revision" : "1447ea20550f6f02c4b48cc80931c3ed40a9c756",
"version" : "0.25.0"
"revision" : "d8163b3a98f3c8434c4361e85126db449d84bc66",
"version" : "0.30.0"
}
},
{
@@ -145,15 +136,6 @@
"version" : "602.0.0"
}
},
{
"identity" : "swift-tagged",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-tagged",
"state" : {
"revision" : "3907a9438f5b57d317001dc99f3f11b46882272b",
"version" : "0.10.0"
}
},
{
"identity" : "xctest-dynamic-overlay",
"kind" : "remoteSourceControl",

View File

@@ -15,12 +15,12 @@ import UIKit
) -> Bool {
// Required for flutter_local_notification
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
}
GeneratedPluginRegistrant.register(with: self)
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
AppDelegate.registerPlugins(with: controller.engine)
AppDelegate.registerPlugins(with: controller.engine, controller: controller)
BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!)
BackgroundServicePlugin.registerBackgroundProcessing()
@@ -51,12 +51,13 @@ import UIKit
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
public static func registerPlugins(with engine: FlutterEngine) {
public static func registerPlugins(with engine: FlutterEngine, controller: FlutterViewController?) {
NativeSyncApiImpl.register(with: engine.registrar(forPlugin: NativeSyncApiImpl.name)!)
LocalImageApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: LocalImageApiImpl())
RemoteImageApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: RemoteImageApiImpl())
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl())
ConnectivityApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ConnectivityApiImpl())
NetworkApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: NetworkApiImpl(viewController: controller))
}
public static func cancelPlugins(with engine: FlutterEngine) {

View File

@@ -95,7 +95,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
// Register plugins in the new engine
GeneratedPluginRegistrant.register(with: engine)
// Register custom plugins
AppDelegate.registerPlugins(with: engine)
AppDelegate.registerPlugins(with: engine, controller: nil)
flutterApi = BackgroundWorkerFlutterApi(binaryMessenger: engine.binaryMessenger)
BackgroundWorkerBgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: self)

View File

@@ -0,0 +1,369 @@
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
import Foundation
#if os(iOS)
import Flutter
#elseif os(macOS)
import FlutterMacOS
#else
#error("Unsupported platform.")
#endif
private func wrapResult(_ result: Any?) -> [Any?] {
return [result]
}
private func wrapError(_ error: Any) -> [Any?] {
if let pigeonError = error as? PigeonError {
return [
pigeonError.code,
pigeonError.message,
pigeonError.details,
]
}
if let flutterError = error as? FlutterError {
return [
flutterError.code,
flutterError.message,
flutterError.details,
]
}
return [
"\(error)",
"\(type(of: error))",
"Stacktrace: \(Thread.callStackSymbols)",
]
}
private func isNullish(_ value: Any?) -> Bool {
return value is NSNull || value == nil
}
private func nilOrValue<T>(_ value: Any?) -> T? {
if value is NSNull { return nil }
return value as! T?
}
func deepEqualsNetwork(_ lhs: Any?, _ rhs: Any?) -> Bool {
let cleanLhs = nilOrValue(lhs) as Any?
let cleanRhs = nilOrValue(rhs) as Any?
switch (cleanLhs, cleanRhs) {
case (nil, nil):
return true
case (nil, _), (_, nil):
return false
case is (Void, Void):
return true
case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable):
return cleanLhsHashable == cleanRhsHashable
case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]):
guard cleanLhsArray.count == cleanRhsArray.count else { return false }
for (index, element) in cleanLhsArray.enumerated() {
if !deepEqualsNetwork(element, cleanRhsArray[index]) {
return false
}
}
return true
case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]):
guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false }
for (key, cleanLhsValue) in cleanLhsDictionary {
guard cleanRhsDictionary.index(forKey: key) != nil else { return false }
if !deepEqualsNetwork(cleanLhsValue, cleanRhsDictionary[key]!) {
return false
}
}
return true
default:
// Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue.
return false
}
}
func deepHashNetwork(value: Any?, hasher: inout Hasher) {
if let valueList = value as? [AnyHashable] {
for item in valueList { deepHashNetwork(value: item, hasher: &hasher) }
return
}
if let valueDict = value as? [AnyHashable: AnyHashable] {
for key in valueDict.keys {
hasher.combine(key)
deepHashNetwork(value: valueDict[key]!, hasher: &hasher)
}
return
}
if let hashableValue = value as? AnyHashable {
hasher.combine(hashableValue.hashValue)
}
return hasher.combine(String(describing: value))
}
/// Generated class from Pigeon that represents data sent in messages.
struct ClientCertData: Hashable {
var data: FlutterStandardTypedData
var password: String
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> ClientCertData? {
let data = pigeonVar_list[0] as! FlutterStandardTypedData
let password = pigeonVar_list[1] as! String
return ClientCertData(
data: data,
password: password
)
}
func toList() -> [Any?] {
return [
data,
password,
]
}
static func == (lhs: ClientCertData, rhs: ClientCertData) -> Bool {
return deepEqualsNetwork(lhs.toList(), rhs.toList()) }
func hash(into hasher: inout Hasher) {
deepHashNetwork(value: toList(), hasher: &hasher)
}
}
/// Generated class from Pigeon that represents data sent in messages.
struct ClientCertPrompt: Hashable {
var title: String
var message: String
var cancel: String
var confirm: String
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> ClientCertPrompt? {
let title = pigeonVar_list[0] as! String
let message = pigeonVar_list[1] as! String
let cancel = pigeonVar_list[2] as! String
let confirm = pigeonVar_list[3] as! String
return ClientCertPrompt(
title: title,
message: message,
cancel: cancel,
confirm: confirm
)
}
func toList() -> [Any?] {
return [
title,
message,
cancel,
confirm,
]
}
static func == (lhs: ClientCertPrompt, rhs: ClientCertPrompt) -> Bool {
return deepEqualsNetwork(lhs.toList(), rhs.toList()) }
func hash(into hasher: inout Hasher) {
deepHashNetwork(value: toList(), hasher: &hasher)
}
}
/// Generated class from Pigeon that represents data sent in messages.
struct WebSocketTaskResult: Hashable {
var taskPointer: Int64
var taskProtocol: String? = nil
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> WebSocketTaskResult? {
let taskPointer = pigeonVar_list[0] as! Int64
let taskProtocol: String? = nilOrValue(pigeonVar_list[1])
return WebSocketTaskResult(
taskPointer: taskPointer,
taskProtocol: taskProtocol
)
}
func toList() -> [Any?] {
return [
taskPointer,
taskProtocol,
]
}
static func == (lhs: WebSocketTaskResult, rhs: WebSocketTaskResult) -> Bool {
return deepEqualsNetwork(lhs.toList(), rhs.toList()) }
func hash(into hasher: inout Hasher) {
deepHashNetwork(value: toList(), hasher: &hasher)
}
}
private class NetworkPigeonCodecReader: FlutterStandardReader {
override func readValue(ofType type: UInt8) -> Any? {
switch type {
case 129:
return ClientCertData.fromList(self.readValue() as! [Any?])
case 130:
return ClientCertPrompt.fromList(self.readValue() as! [Any?])
case 131:
return WebSocketTaskResult.fromList(self.readValue() as! [Any?])
default:
return super.readValue(ofType: type)
}
}
}
private class NetworkPigeonCodecWriter: FlutterStandardWriter {
override func writeValue(_ value: Any) {
if let value = value as? ClientCertData {
super.writeByte(129)
super.writeValue(value.toList())
} else if let value = value as? ClientCertPrompt {
super.writeByte(130)
super.writeValue(value.toList())
} else if let value = value as? WebSocketTaskResult {
super.writeByte(131)
super.writeValue(value.toList())
} else {
super.writeValue(value)
}
}
}
private class NetworkPigeonCodecReaderWriter: FlutterStandardReaderWriter {
override func reader(with data: Data) -> FlutterStandardReader {
return NetworkPigeonCodecReader(data: data)
}
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
return NetworkPigeonCodecWriter(data: data)
}
}
class NetworkPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
static let shared = NetworkPigeonCodec(readerWriter: NetworkPigeonCodecReaderWriter())
}
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol NetworkApi {
func addCertificate(clientData: ClientCertData, completion: @escaping (Result<Void, Error>) -> Void)
func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<ClientCertData, Error>) -> Void)
func removeCertificate(completion: @escaping (Result<Void, Error>) -> Void)
func getClientPointer() throws -> Int64
/// iOS only - creates a WebSocket task and waits for connection to be established.
func createWebSocketTask(url: String, protocols: [String]?, completion: @escaping (Result<WebSocketTaskResult, Error>) -> Void)
func setRequestHeaders(headers: [String: String]) throws
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class NetworkApiSetup {
static var codec: FlutterStandardMessageCodec { NetworkPigeonCodec.shared }
/// Sets up an instance of `NetworkApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: NetworkApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
let addCertificateChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.addCertificate\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
addCertificateChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let clientDataArg = args[0] as! ClientCertData
api.addCertificate(clientData: clientDataArg) { result in
switch result {
case .success:
reply(wrapResult(nil))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
addCertificateChannel.setMessageHandler(nil)
}
let selectCertificateChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.selectCertificate\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
selectCertificateChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let promptTextArg = args[0] as! ClientCertPrompt
api.selectCertificate(promptText: promptTextArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
selectCertificateChannel.setMessageHandler(nil)
}
let removeCertificateChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.removeCertificate\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
removeCertificateChannel.setMessageHandler { _, reply in
api.removeCertificate { result in
switch result {
case .success:
reply(wrapResult(nil))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
removeCertificateChannel.setMessageHandler(nil)
}
let getClientPointerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
getClientPointerChannel.setMessageHandler { _, reply in
do {
let result = try api.getClientPointer()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
getClientPointerChannel.setMessageHandler(nil)
}
/// iOS only - creates a WebSocket task and waits for connection to be established.
let createWebSocketTaskChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.createWebSocketTask\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
createWebSocketTaskChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let urlArg = args[0] as! String
let protocolsArg: [String]? = nilOrValue(args[1])
api.createWebSocketTask(url: urlArg, protocols: protocolsArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
createWebSocketTaskChannel.setMessageHandler(nil)
}
let setRequestHeadersChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
setRequestHeadersChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let headersArg = args[0] as! [String: String]
do {
try api.setRequestHeaders(headers: headersArg)
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
setRequestHeadersChannel.setMessageHandler(nil)
}
}
}

View File

@@ -0,0 +1,190 @@
import Foundation
import UniformTypeIdentifiers
import native_video_player
enum ImportError: Error {
case noFile
case noViewController
case keychainError(OSStatus)
case cancelled
}
class NetworkApiImpl: NetworkApi {
weak var viewController: UIViewController?
private var activeImporter: CertImporter?
init(viewController: UIViewController?) {
self.viewController = viewController
}
func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<ClientCertData, any Error>) -> Void) {
let importer = CertImporter(promptText: promptText, completion: { [weak self] result in
self?.activeImporter = nil
completion(result.map { ClientCertData(data: FlutterStandardTypedData(bytes: $0.0), password: $0.1) })
}, viewController: viewController)
activeImporter = importer
importer.load()
}
func removeCertificate(completion: @escaping (Result<Void, any Error>) -> Void) {
let status = clearCerts()
if status == errSecSuccess || status == errSecItemNotFound {
VideoResourceLoader.shared.clientCredential = nil
return completion(.success(()))
}
completion(.failure(ImportError.keychainError(status)))
}
func addCertificate(clientData: ClientCertData, completion: @escaping (Result<Void, any Error>) -> Void) {
let status = importCert(clientData: clientData.data.data, password: clientData.password)
if status == errSecSuccess {
return completion(.success(()))
}
completion(.failure(ImportError.keychainError(status)))
}
func getClientPointer() throws -> Int64 {
let pointer = URLSessionManager.shared.sessionPointer
return Int64(Int(bitPattern: pointer))
}
func createWebSocketTask(
url: String,
protocols: [String]?,
completion: @escaping (Result<WebSocketTaskResult, any Error>) -> Void
) {
guard let wsUrl = URL(string: url) else {
completion(.failure(WebSocketError.invalidURL(url)))
return
}
URLSessionManager.shared.createWebSocketTask(url: wsUrl, protocols: protocols) { result in
switch result {
case .success(let (task, proto)):
let pointer = Unmanaged.passUnretained(task).toOpaque()
let address = Int64(Int(bitPattern: pointer))
completion(.success(WebSocketTaskResult(taskPointer: address, taskProtocol: proto)))
case .failure(let error):
completion(.failure(error))
}
}
}
func setRequestHeaders(headers: [String : String]) throws {
URLSessionManager.shared.session.configuration.httpAdditionalHeaders = headers
}
}
private class CertImporter: NSObject, UIDocumentPickerDelegate {
private let promptText: ClientCertPrompt
private var completion: ((Result<(Data, String), Error>) -> Void)
private weak var viewController: UIViewController?
init(promptText: ClientCertPrompt, completion: (@escaping (Result<(Data, String), Error>) -> Void), viewController: UIViewController?) {
self.promptText = promptText
self.completion = completion
self.viewController = viewController
}
func load() {
guard let vc = viewController else { return completion(.failure(ImportError.noViewController)) }
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [
UTType(filenameExtension: "p12")!,
UTType(filenameExtension: "pfx")!,
])
picker.delegate = self
picker.allowsMultipleSelection = false
vc.present(picker, animated: true)
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first else {
return completion(.failure(ImportError.noFile))
}
Task { @MainActor in
do {
let data = try readSecurityScoped(url: url)
guard let password = await promptForPassword() else {
return completion(.failure(ImportError.cancelled))
}
let status = importCert(clientData: data, password: password)
if status != errSecSuccess {
return completion(.failure(ImportError.keychainError(status)))
}
await URLSessionManager.shared.session.flush()
self.completion(.success((data, password)))
} catch {
completion(.failure(error))
}
}
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
completion(.failure(ImportError.cancelled))
}
private func promptForPassword() async -> String? {
guard let vc = viewController else { return nil }
return await withCheckedContinuation { continuation in
let alert = UIAlertController(
title: promptText.title,
message: promptText.message,
preferredStyle: .alert
)
alert.addTextField { $0.isSecureTextEntry = true }
alert.addAction(UIAlertAction(title: promptText.cancel, style: .cancel) { _ in
continuation.resume(returning: nil)
})
alert.addAction(UIAlertAction(title: promptText.confirm, style: .default) { _ in
continuation.resume(returning: alert.textFields?.first?.text ?? "")
})
vc.present(alert, animated: true)
}
}
private func readSecurityScoped(url: URL) throws -> Data {
guard url.startAccessingSecurityScopedResource() else {
throw ImportError.noFile
}
defer { url.stopAccessingSecurityScopedResource() }
return try Data(contentsOf: url)
}
}
private func importCert(clientData: Data, password: String) -> OSStatus {
let options = [kSecImportExportPassphrase: password] as CFDictionary
var items: CFArray?
let status = SecPKCS12Import(clientData as CFData, options, &items)
guard status == errSecSuccess,
let array = items as? [[String: Any]],
let first = array.first,
let identity = first[kSecImportItemIdentity as String] else {
return status
}
clearCerts()
let addQuery: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecValueRef as String: identity,
kSecAttrLabel as String: CLIENT_CERT_LABEL,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
return SecItemAdd(addQuery as CFDictionary, nil)
}
@discardableResult private func clearCerts() -> OSStatus {
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: CLIENT_CERT_LABEL,
]
return SecItemDelete(deleteQuery as CFDictionary)
}

View File

@@ -0,0 +1,171 @@
import Foundation
import native_video_player
let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity"
/// Manages a shared URLSession with SSL configuration support.
class URLSessionManager: NSObject {
static let shared = URLSessionManager()
let session: URLSession
let delegate: URLSessionManagerDelegate
private let configuration = {
let config = URLSessionConfiguration.default
let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
.first!
.appendingPathComponent("api", isDirectory: true)
try! FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
config.urlCache = URLCache(
memoryCapacity: 0,
diskCapacity: 1024 * 1024 * 1024,
directory: cacheDir
)
config.httpMaximumConnectionsPerHost = 64
config.timeoutIntervalForRequest = 60
config.timeoutIntervalForResource = 300
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
config.httpAdditionalHeaders = ["User-Agent": "Immich_iOS_\(version)"]
return config
}()
var sessionPointer: UnsafeMutableRawPointer {
Unmanaged.passUnretained(session).toOpaque()
}
private override init() {
delegate = URLSessionManagerDelegate()
session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil)
super.init()
}
/// Creates a WebSocket task and waits for connection to be established.
func createWebSocketTask(
url: URL,
protocols: [String]?,
completion: @escaping (Result<(URLSessionWebSocketTask, String?), Error>) -> Void
) {
let task: URLSessionWebSocketTask
if let protocols = protocols, !protocols.isEmpty {
task = session.webSocketTask(with: url, protocols: protocols)
} else {
task = session.webSocketTask(with: url)
}
delegate.registerWebSocketTask(task) { result in
completion(result)
}
task.resume()
}
}
enum WebSocketError: Error {
case connectionFailed(String)
case invalidURL(String)
}
class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWebSocketDelegate {
private var webSocketCompletions: [Int: (Result<(URLSessionWebSocketTask, String?), Error>) -> Void] = [:]
private let lock = {
let lock = UnsafeMutablePointer<os_unfair_lock>.allocate(capacity: 1)
lock.initialize(to: os_unfair_lock())
return lock
}()
func registerWebSocketTask(
_ task: URLSessionWebSocketTask,
completion: @escaping (Result<(URLSessionWebSocketTask, String?), Error>) -> Void
) {
os_unfair_lock_lock(lock)
webSocketCompletions[task.taskIdentifier] = completion
os_unfair_lock_unlock(lock)
}
func urlSession(
_ session: URLSession,
webSocketTask: URLSessionWebSocketTask,
didOpenWithProtocol protocol: String?
) {
os_unfair_lock_lock(lock)
let completion = webSocketCompletions.removeValue(forKey: webSocketTask.taskIdentifier)
os_unfair_lock_unlock(lock)
completion?(.success((webSocketTask, `protocol`)))
}
func urlSession(
_ session: URLSession,
webSocketTask: URLSessionWebSocketTask,
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
reason: Data?
) {
// Close events are handled by CupertinoWebSocket via task.closeCode/closeReason
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?
) {
guard let webSocketTask = task as? URLSessionWebSocketTask else { return }
os_unfair_lock_lock(lock)
let completion = webSocketCompletions.removeValue(forKey: webSocketTask.taskIdentifier)
os_unfair_lock_unlock(lock)
if let error = error {
completion?(.failure(error))
}
}
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
handleChallenge(challenge, completionHandler: completionHandler)
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
handleChallenge(challenge, completionHandler: completionHandler)
}
func handleChallenge(
_ challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
switch challenge.protectionSpace.authenticationMethod {
case NSURLAuthenticationMethodClientCertificate: handleClientCertificate(completion: completionHandler)
default: completionHandler(.performDefaultHandling, nil)
}
}
private func handleClientCertificate(
completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
let query: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: CLIENT_CERT_LABEL,
kSecReturnRef as String: true,
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status == errSecSuccess, let identity = item {
let credential = URLCredential(identity: identity as! SecIdentity,
certificates: nil,
persistence: .forSession)
VideoResourceLoader.shared.clientCredential = credential
return completion(.useCredential, credential)
}
completion(.performDefaultHandling, nil)
}
}

View File

@@ -0,0 +1,7 @@
import Foundation
enum ImageProcessing {
static let queue = DispatchQueue(label: "thumbnail.processing", qos: .userInitiated, attributes: .concurrent)
static let semaphore = DispatchSemaphore(value: ProcessInfo.processInfo.activeProcessorCount * 2)
static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil)
}

View File

@@ -34,7 +34,6 @@ class LocalImageApiImpl: LocalImageApi {
private static let assetQueue = DispatchQueue(label: "thumbnail.assets", qos: .userInitiated)
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated)
private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default)
private static let processingQueue = DispatchQueue(label: "thumbnail.processing", qos: .userInteractive, attributes: .concurrent)
private static var rgbaFormat = vImage_CGImageFormat(
bitsPerComponent: 8,
@@ -44,8 +43,6 @@ class LocalImageApiImpl: LocalImageApi {
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
@@ -53,7 +50,7 @@ class LocalImageApiImpl: LocalImageApi {
}()
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
Self.processingQueue.async {
ImageProcessing.queue.async {
guard let data = Data(base64Encoded: thumbhash)
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
@@ -71,16 +68,16 @@ class LocalImageApiImpl: LocalImageApi {
let request = LocalImageRequest(callback: completion)
let item = DispatchWorkItem {
if request.isCancelled {
return completion(Self.cancelledResult)
return completion(ImageProcessing.cancelledResult)
}
Self.concurrencySemaphore.wait()
ImageProcessing.semaphore.wait()
defer {
Self.concurrencySemaphore.signal()
ImageProcessing.semaphore.signal()
}
if request.isCancelled {
return completion(Self.cancelledResult)
return completion(ImageProcessing.cancelledResult)
}
guard let asset = Self.requestAsset(assetId: assetId)
@@ -91,7 +88,7 @@ class LocalImageApiImpl: LocalImageApi {
}
if request.isCancelled {
return completion(Self.cancelledResult)
return completion(ImageProcessing.cancelledResult)
}
var image: UIImage?
@@ -106,7 +103,7 @@ class LocalImageApiImpl: LocalImageApi {
)
if request.isCancelled {
return completion(Self.cancelledResult)
return completion(ImageProcessing.cancelledResult)
}
guard let image = image,
@@ -116,7 +113,7 @@ class LocalImageApiImpl: LocalImageApi {
}
if request.isCancelled {
return completion(Self.cancelledResult)
return completion(ImageProcessing.cancelledResult)
}
do {
@@ -124,7 +121,7 @@ class LocalImageApiImpl: LocalImageApi {
if request.isCancelled {
buffer.free()
return completion(Self.cancelledResult)
return completion(ImageProcessing.cancelledResult)
}
request.callback(.success([
@@ -142,7 +139,7 @@ class LocalImageApiImpl: LocalImageApi {
request.workItem = item
Self.add(requestId: requestId, request: request)
Self.processingQueue.async(execute: item)
ImageProcessing.queue.async(execute: item)
}
func cancelRequest(requestId: Int64) {
@@ -163,7 +160,7 @@ class LocalImageApiImpl: LocalImageApi {
request.isCancelled = true
guard let item = request.workItem else { return }
if item.isCancelled {
cancelQueue.async { request.callback(Self.cancelledResult) }
cancelQueue.async { request.callback(ImageProcessing.cancelledResult) }
}
}
}

View File

@@ -70,7 +70,7 @@ class RemoteImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable
/// 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 requestImage(url: String, requestId: Int64, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
func cancelRequest(requestId: Int64) throws
func clearCache(completion: @escaping (Result<Int64, Error>) -> Void)
}
@@ -86,9 +86,8 @@ class RemoteImageApiSetup {
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
let requestIdArg = args[1] as! Int64
api.requestImage(url: urlArg, requestId: requestIdArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))

View File

@@ -7,64 +7,18 @@ 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 cacheDir = FileManager.default.temporaryDirectory.appendingPathComponent("thumbnails", isDirectory: true)
let config = URLSessionConfiguration.default
config.requestCachePolicy = .returnCacheDataElseLoad
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
config.httpAdditionalHeaders = ["User-Agent": "Immich_iOS_\(version)"]
try! FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
config.urlCache = URLCache(
memoryCapacity: 0,
diskCapacity: 1 << 30,
directory: cacheDir
)
config.httpMaximumConnectionsPerHost = 64
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)
}
func clearCache(completion: @escaping (Result<Int64, any Error>) -> Void) {
Task {
let cache = Self.session.configuration.urlCache!
let cacheSize = Int64(cache.currentDiskUsage)
cache.removeAllCachedResponses()
completion(.success(cacheSize))
}
}
}
class RemoteImageApiDelegate: NSObject, URLSessionDataDelegate {
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated, attributes: .concurrent)
private static var lock = os_unfair_lock()
private static var requests = [Int64: RemoteImageRequest]()
private static var rgbaFormat = vImage_CGImageFormat(
bitsPerComponent: 8,
bitsPerPixel: 32,
@@ -72,9 +26,6 @@ class RemoteImageApiDelegate: NSObject, URLSessionDataDelegate {
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,
@@ -82,105 +33,100 @@ class RemoteImageApiDelegate: NSObject, URLSessionDataDelegate {
kCGImageSourceCreateThumbnailFromImageAlways: 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)
func requestImage(url: String, requestId: Int64, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
var urlRequest = URLRequest(url: URL(string: url)!)
urlRequest.cachePolicy = .returnCacheDataElseLoad
let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in
Self.handleCompletion(requestId: requestId, data: data, response: response, error: error)
}
let capacity = max(Int(response.expectedContentLength), 0)
request.data = CFDataCreateMutable(nil, capacity)
let request = RemoteImageRequest(id: requestId, task: task, completion: completion)
completionHandler(.allow)
os_unfair_lock_lock(&Self.lock)
Self.requests[requestId] = request
os_unfair_lock_unlock(&Self.lock)
task.resume()
}
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)
private static func handleCompletion(requestId: Int64, data: Data?, response: URLResponse?, error: Error?) {
os_unfair_lock_lock(&Self.lock)
guard let request = requests[requestId] else {
return os_unfair_lock_unlock(&Self.lock)
}
}
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) }
requests[requestId] = nil
os_unfair_lock_unlock(&Self.lock)
if let error = error {
if request.isCancelled || (error as NSError).code == NSURLErrorCancelled {
return request.completion(Self.cancelledResult)
return request.completion(ImageProcessing.cancelledResult)
}
return request.completion(.failure(error))
}
if request.isCancelled {
return request.completion(Self.cancelledResult)
return request.completion(ImageProcessing.cancelledResult)
}
guard let data = request.data else {
guard let data = 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)
ImageProcessing.queue.async {
ImageProcessing.semaphore.wait()
defer { ImageProcessing.semaphore.signal() }
if request.isCancelled {
buffer.free()
return request.completion(Self.cancelledResult)
return request.completion(ImageProcessing.cancelledResult)
}
request.completion(
.success([
"pointer": Int64(Int(bitPattern: buffer.data)),
"width": Int64(buffer.width),
"height": Int64(buffer.height),
"rowBytes": Int64(buffer.rowBytes),
]))
} catch {
return request.completion(.failure(PigeonError(code: "", message: "Failed to convert image for request: \(error)", details: nil)))
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil),
let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions) else {
return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil)))
}
if request.isCancelled {
return request.completion(ImageProcessing.cancelledResult)
}
do {
let buffer = try vImage_Buffer(cgImage: cgImage, format: rgbaFormat)
if request.isCancelled {
buffer.free()
return request.completion(ImageProcessing.cancelledResult)
}
request.completion(
.success([
"pointer": Int64(Int(bitPattern: buffer.data)),
"width": Int64(buffer.width),
"height": Int64(buffer.height),
"rowBytes": Int64(buffer.rowBytes),
]))
} catch {
return request.completion(.failure(PigeonError(code: "", message: "Failed to convert image for request: \(error)", details: nil)))
}
}
}
@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 }
func cancelRequest(requestId: Int64) {
os_unfair_lock_lock(&Self.lock)
let request = Self.requests[requestId]
os_unfair_lock_unlock(&Self.lock)
guard let request = request else { return }
request.isCancelled = true
request.task?.cancel()
}
func clearCache(completion: @escaping (Result<Int64, any Error>) -> Void) {
Task {
let cache = URLSessionManager.shared.session.configuration.urlCache!
let cacheSize = Int64(cache.currentDiskUsage)
cache.removeAllCachedResponses()
completion(.success(cacheSize))
}
}
}

View File

@@ -3,7 +3,6 @@ import 'dart:io';
import 'dart:ui';
import 'package:background_downloader/background_downloader.dart';
import 'package:cancellation_token_http/http.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
@@ -28,7 +27,6 @@ import 'package:immich_mobile/services/localization.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/wm_executor.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
@@ -64,7 +62,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
final Drift _drift;
final DriftLogger _driftLogger;
final BackgroundWorkerBgHostApi _backgroundHostApi;
final CancellationToken _cancellationToken = CancellationToken();
final _cancellationToken = Completer();
final Logger _logger = Logger('BackgroundWorkerBgService');
bool _isCleanedUp = false;
@@ -88,8 +86,6 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
Future<void> init() async {
try {
HttpSSLOptions.apply(applyNative: false);
await Future.wait(
[
loadTranslations(),
@@ -198,7 +194,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
_ref?.dispose();
_ref = null;
_cancellationToken.cancel();
_cancellationToken.complete();
_logger.info("Cleaning up background worker");
final cleanupFutures = [

View File

@@ -41,7 +41,7 @@ class HashService {
final Stopwatch stopwatch = Stopwatch()..start();
try {
// Migrate hashes from cloud ID to local ID so we don't have to re-hash them
await _localAssetRepository.reconcileHashesFromCloudId();
// await _localAssetRepository.reconcileHashesFromCloudId();
// Sorted by backupSelection followed by isCloud
final localAlbums = await _localAlbumRepository.getBackupAlbums();

View File

@@ -19,6 +19,7 @@ import 'package:logging/logging.dart';
class LocalSyncService {
final DriftLocalAlbumRepository _localAlbumRepository;
// ignore: unused_field
final DriftLocalAssetRepository _localAssetRepository;
final NativeSyncApi _nativeSyncApi;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
@@ -53,8 +54,8 @@ class LocalSyncService {
}
if (CurrentPlatform.isIOS) {
final assets = await _localAssetRepository.getEmptyCloudIdAssets();
await _mapIosCloudIds(assets);
// final assets = await _localAssetRepository.getEmptyCloudIdAssets();
// await _mapIosCloudIds(assets);
}
if (full || await _nativeSyncApi.shouldFullSync()) {
@@ -306,27 +307,28 @@ class LocalSyncService {
return true;
}
// ignore: avoid-unused-parameters
Future<void> _mapIosCloudIds(List<LocalAsset> assets) async {
if (!CurrentPlatform.isIOS || assets.isEmpty) {
return;
}
// if (!CurrentPlatform.isIOS || assets.isEmpty) {
return;
// }
final assetIds = assets.map((a) => a.id).toList();
final cloudMapping = <String, String>{};
final cloudIds = await _nativeSyncApi.getCloudIdForAssetIds(assetIds);
for (int i = 0; i < cloudIds.length; i++) {
final cloudIdResult = cloudIds[i];
if (cloudIdResult.cloudId != null) {
cloudMapping[cloudIdResult.assetId] = cloudIdResult.cloudId!;
} else {
final asset = assets.firstWhereOrNull((a) => a.id == cloudIdResult.assetId);
_log.fine(
"Cannot fetch cloudId for asset with id: ${cloudIdResult.assetId}, name: ${asset?.name}, createdAt: ${asset?.createdAt}. Error: ${cloudIdResult.error ?? "unknown"}",
);
}
}
// final assetIds = assets.map((a) => a.id).toList();
// final cloudMapping = <String, String>{};
// final cloudIds = await _nativeSyncApi.getCloudIdForAssetIds(assetIds);
// for (int i = 0; i < cloudIds.length; i++) {
// final cloudIdResult = cloudIds[i];
// if (cloudIdResult.cloudId != null) {
// cloudMapping[cloudIdResult.assetId] = cloudIdResult.cloudId!;
// } else {
// final asset = assets.firstWhereOrNull((a) => a.id == cloudIdResult.assetId);
// _log.fine(
// "Cannot fetch cloudId for asset with id: ${cloudIdResult.assetId}, name: ${asset?.name}, createdAt: ${asset?.createdAt}. Error: ${cloudIdResult.error ?? "unknown"}",
// );
// }
// }
await _localAlbumRepository.updateCloudMapping(cloudMapping);
// await _localAlbumRepository.updateCloudMapping(cloudMapping);
}
bool _assetsEqual(LocalAsset a, LocalAsset b) {

View File

@@ -227,6 +227,13 @@ class TimelineService {
return _buffer.elementAt(index - _bufferOffset);
}
/// Finds the index of an asset by its heroTag within the current buffer.
/// Returns null if the asset is not found in the buffer.
int? getIndex(String heroTag) {
final index = _buffer.indexWhere((a) => a.heroTag == heroTag);
return index >= 0 ? _bufferOffset + index : null;
}
Future<void> dispose() async {
await _bucketSubscription?.cancel();
_bucketSubscription = null;

View File

@@ -85,14 +85,14 @@ LIMIT $limit;
mergedBucket(:group_by AS INTEGER):
SELECT
COUNT(*) as asset_count,
CASE
WHEN :group_by = 0 THEN STRFTIME('%Y-%m-%d', created_at, 'localtime') -- day
WHEN :group_by = 1 THEN STRFTIME('%Y-%m', created_at, 'localtime') -- month
END AS bucket_date
bucket_date
FROM
(
SELECT
rae.created_at
CASE
WHEN :group_by = 0 THEN STRFTIME('%Y-%m-%d', rae.local_date_time)
WHEN :group_by = 1 THEN STRFTIME('%Y-%m', rae.local_date_time)
END as bucket_date
FROM
remote_asset_entity rae
LEFT JOIN
@@ -107,7 +107,10 @@ FROM
)
UNION ALL
SELECT
lae.created_at
CASE
WHEN :group_by = 0 THEN STRFTIME('%Y-%m-%d', lae.created_at, 'localtime')
WHEN :group_by = 1 THEN STRFTIME('%Y-%m', lae.created_at, 'localtime')
END as bucket_date
FROM
local_asset_entity lae
WHERE NOT EXISTS (

View File

@@ -79,7 +79,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
final expandeduserIds = $expandVar($arrayStartIndex, userIds.length);
$arrayStartIndex += userIds.length;
return customSelect(
'SELECT COUNT(*) AS asset_count, CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', created_at, \'localtime\') END AS bucket_date FROM (SELECT rae.created_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT lae.created_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2)) GROUP BY bucket_date ORDER BY bucket_date DESC',
'SELECT COUNT(*) AS asset_count, bucket_date FROM (SELECT CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', rae.local_date_time) WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', rae.local_date_time) END AS bucket_date FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', lae.created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', lae.created_at, \'localtime\') END AS bucket_date FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2)) GROUP BY bucket_date ORDER BY bucket_date DESC',
variables: [
i0.Variable<int>(groupBy),
for (var $ in userIds) i0.Variable<String>($),

View File

@@ -2,9 +2,8 @@ part of 'image_request.dart';
class RemoteImageRequest extends ImageRequest {
final String uri;
final Map<String, String> headers;
RemoteImageRequest({required this.uri, required this.headers});
RemoteImageRequest({required this.uri});
@override
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
@@ -12,7 +11,7 @@ class RemoteImageRequest extends ImageRequest {
return null;
}
final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId);
final info = await remoteImageApi.requestImage(uri, 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} =>

View File

@@ -228,7 +228,9 @@ class Drift extends $Drift implements IDatabaseRepository {
await customStatement('PRAGMA foreign_keys = ON');
await customStatement('PRAGMA synchronous = NORMAL');
await customStatement('PRAGMA journal_mode = WAL');
await customStatement('PRAGMA busy_timeout = 30000');
await customStatement('PRAGMA busy_timeout = 30000'); // 30s
await customStatement('PRAGMA cache_size = -32000'); // 32MB
await customStatement('PRAGMA temp_store = MEMORY');
},
);
}

View File

@@ -22,6 +22,7 @@ class DriftLogger extends $DriftLogger implements IDatabaseRepository {
await customStatement('PRAGMA synchronous = NORMAL');
await customStatement('PRAGMA journal_mode = WAL');
await customStatement('PRAGMA busy_timeout = 500');
await customStatement('PRAGMA temp_store = MEMORY');
},
);
}

View File

@@ -1,67 +1,65 @@
import 'dart:ffi';
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';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:ok_http/ok_http.dart';
import 'package:web_socket/web_socket.dart';
class NetworkRepository {
static late Directory _cachePath;
static late String _userAgent;
static final _clients = <String, http.Client>{};
static http.Client? _client;
static late int _clientPointer;
static Future<void> init() {
return (
getTemporaryDirectory().then((cachePath) => _cachePath = cachePath),
getUserAgentString().then((userAgent) => _userAgent = userAgent),
).wait;
static Future<void> init() async {
_clientPointer = await networkApi.getClientPointer();
_client?.close();
if (Platform.isIOS) {
_client = _createIOSClient(_clientPointer);
} else {
_client = _createAndroidClient(_clientPointer);
}
}
static void reset() {
Future.microtask(init);
for (final client in _clients.values) {
client.close();
// ignore: avoid-unused-parameters
static Future<WebSocket> createWebSocket(Uri uri, {Map<String, String>? headers, Iterable<String>? protocols}) {
if (Platform.isIOS) {
return _createIOSWebSocket(uri, protocols: protocols);
} else {
return _createAndroidWebSocket(uri, protocols: protocols);
}
_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;
}
/// Returns a shared HTTP client that uses native SSL configuration.
///
/// On iOS: Uses SharedURLSessionManager's URLSession.
/// On Android: Uses SharedHttpClientManager's OkHttpClient.
///
/// Must call [init] before using this method.
static http.Client get client => _client!;
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);
}
static http.Client _createIOSClient(int address) {
final pointer = Pointer.fromAddress(address);
final session = URLSession.fromRawPointer(pointer.cast());
return CupertinoClient.fromSharedSession(session);
}
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);
static http.Client _createAndroidClient(int address) {
final pointer = Pointer<Void>.fromAddress(address);
return OkHttpClient.fromJniGlobalRef(pointer);
}
static Future<WebSocket> _createIOSWebSocket(Uri uri, {Iterable<String>? protocols}) async {
final result = await networkApi.createWebSocketTask(uri.toString(), protocols?.toList());
final pointer = Pointer.fromAddress(result.taskPointer);
final task = URLSessionWebSocketTask.fromRawPointer(pointer.cast());
return CupertinoWebSocket.fromConnectedTask(task, protocol: result.taskProtocol ?? '');
}
static Future<WebSocket> _createAndroidWebSocket(Uri uri, {Iterable<String>? protocols}) {
final pointer = Pointer<Void>.fromAddress(_clientPointer);
return OkHttpWebSocket.connectFromJniGlobalRef(pointer, uri, protocols: protocols);
}
}

View File

@@ -6,6 +6,7 @@ import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
@@ -30,15 +31,11 @@ class SyncApiRepository {
http.Client? httpClient,
}) async {
final stopwatch = Stopwatch()..start();
final client = httpClient ?? http.Client();
final client = httpClient ?? NetworkRepository.client;
final endpoint = "${_api.apiClient.basePath}/sync/stream";
final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'};
final headerParams = <String, String>{};
await _api.applyToParams([], headerParams);
headers.addAll(headerParams);
final shouldReset = Store.get(StoreKey.shouldResetSync, false);
final request = http.Request('POST', Uri.parse(endpoint));
request.headers.addAll(headers);
@@ -116,8 +113,6 @@ class SyncApiRepository {
}
} catch (error, stack) {
return Future.error(error, stack);
} finally {
client.close();
}
stopwatch.stop();
_logger.info("Remote Sync completed in ${stopwatch.elapsed.inMilliseconds}ms");

View File

@@ -126,7 +126,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
}
final assetCountExp = _db.localAssetEntity.id.count();
final dateExp = _db.localAssetEntity.createdAt.dateFmt(groupBy);
final dateExp = _db.localAssetEntity.createdAt.dateFmt(groupBy, toLocal: true);
final query =
_db.localAssetEntity.selectOnly().join([
@@ -203,7 +203,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
final album = albums.first;
final isAscending = album.order == AlbumAssetOrder.asc;
final assetCountExp = _db.remoteAssetEntity.id.count();
final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy);
final dateExp = _db.remoteAssetEntity.localDateTime.dateFmt(groupBy);
final query = _db.remoteAssetEntity.selectOnly()
..addColumns([assetCountExp, dateExp])
@@ -280,7 +280,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
final sorted = List<BaseAsset>.from(assets)..sort((a, b) => b.createdAt.compareTo(a.createdAt));
final Map<DateTime, int> bucketCounts = {};
for (final asset in sorted) {
final date = DateTime(asset.createdAt.year, asset.createdAt.month, asset.createdAt.day);
final localTime = asset.createdAt.toLocal();
final date = DateTime(localTime.year, localTime.month, localTime.day);
bucketCounts[date] = (bucketCounts[date] ?? 0) + 1;
}
@@ -360,7 +361,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
}
final assetCountExp = _db.remoteAssetEntity.id.count();
final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy);
final dateExp = _db.remoteAssetEntity.localDateTime.dateFmt(groupBy);
final query = _db.remoteAssetEntity.selectOnly()
..addColumns([assetCountExp, dateExp])
@@ -430,7 +431,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
}
final assetCountExp = _db.remoteAssetEntity.id.count();
final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy);
final dateExp = _db.remoteAssetEntity.localDateTime.dateFmt(groupBy);
final query = _db.remoteAssetEntity.selectOnly()
..addColumns([assetCountExp, dateExp])
@@ -500,7 +501,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
}
final assetCountExp = _db.remoteAssetEntity.id.count();
final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy);
final dateExp = _db.remoteAssetEntity.localDateTime.dateFmt(groupBy);
final query = _db.remoteAssetEntity.selectOnly()
..addColumns([assetCountExp, dateExp])
@@ -602,7 +603,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
}
final assetCountExp = _db.remoteAssetEntity.id.count();
final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy);
final dateExp = _db.remoteAssetEntity.localDateTime.dateFmt(groupBy);
final query = _db.remoteAssetEntity.selectOnly()
..addColumns([assetCountExp, dateExp])
@@ -664,10 +665,11 @@ List<Bucket> _generateBuckets(int count) {
}
extension on Expression<DateTime> {
Expression<String> dateFmt(GroupAssetsBy groupBy) {
Expression<String> dateFmt(GroupAssetsBy groupBy, {bool toLocal = false}) {
// DateTimes are stored in UTC, so we need to convert them to local time inside the query before formatting
// to create the correct time bucket
final localTimeExp = modify(const DateTimeModifier.localTime());
// to create the correct time bucket when toLocal is true
// toLocal is false for remote assets where localDateTime is already in the correct timezone
final localTimeExp = toLocal ? modify(const DateTimeModifier.localTime()) : this;
return switch (groupBy) {
GroupAssetsBy.day || GroupAssetsBy.auto => localTimeExp.date,
GroupAssetsBy.month => localTimeExp.strftime("%Y-%m"),

View File

@@ -39,7 +39,6 @@ import 'package:immich_mobile/theme/theme_data.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/cache/widgets_binding.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/utils/licenses.dart';
import 'package:immich_mobile/utils/migration.dart';
import 'package:immich_mobile/wm_executor.dart';
@@ -57,7 +56,6 @@ void main() async {
// Warm-up isolate pool for worker manager
await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5));
await migrateDatabaseIfNeeded(isar, drift);
HttpSSLOptions.apply();
runApp(
ProviderScope(
@@ -241,7 +239,7 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
@override
void reassemble() {
if (kDebugMode) {
NetworkRepository.reset();
NetworkRepository.init();
}
super.reassemble();
}

View File

@@ -1,6 +1,7 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'package:cancellation_token_http/http.dart';
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
@@ -21,7 +22,7 @@ class BackUpState {
final DateTime progressInFileSpeedUpdateTime;
final int progressInFileSpeedUpdateSentBytes;
final double iCloudDownloadProgress;
final CancellationToken cancelToken;
final Completer cancelToken;
final ServerDiskInfo serverInfo;
final bool autoBackup;
final bool backgroundBackup;
@@ -78,7 +79,7 @@ class BackUpState {
DateTime? progressInFileSpeedUpdateTime,
int? progressInFileSpeedUpdateSentBytes,
double? iCloudDownloadProgress,
CancellationToken? cancelToken,
Completer? cancelToken,
ServerDiskInfo? serverInfo,
bool? autoBackup,
bool? backgroundBackup,

View File

@@ -1,10 +1,11 @@
import 'package:cancellation_token_http/http.dart';
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
class ManualUploadState {
final CancellationToken cancelToken;
final Completer cancelToken;
// Current Backup Asset
final CurrentUploadAsset currentUploadAsset;
@@ -44,7 +45,7 @@ class ManualUploadState {
List<double>? progressInFileSpeeds,
DateTime? progressInFileSpeedUpdateTime,
int? progressInFileSpeedUpdateSentBytes,
CancellationToken? cancelToken,
Completer? cancelToken,
CurrentUploadAsset? currentUploadAsset,
int? totalAssetsToUpload,
int? successfulUploads,

View File

@@ -2,9 +2,10 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart';
import 'package:intl/intl.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' as base_asset;
@RoutePage()
class FailedBackupStatusPage extends HookConsumerWidget {
@@ -58,7 +59,7 @@ class FailedBackupStatusPage extends HookConsumerWidget {
clipBehavior: Clip.hardEdge,
child: Image(
fit: BoxFit.cover,
image: ImmichLocalThumbnailProvider(asset: errorAsset.asset, height: 512, width: 512),
image: LocalThumbProvider(id: errorAsset.asset.localId!, assetType: base_asset.AssetType.video),
),
),
),

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart';
class GalleryStackedChildren extends HookConsumerWidget {
final ValueNotifier<int> stackIndex;
@@ -70,7 +70,7 @@ class GalleryStackedChildren extends HookConsumerWidget {
borderRadius: const BorderRadius.all(Radius.circular(4)),
child: Image(
fit: BoxFit.cover,
image: ImmichRemoteImageProvider(assetId: assetId),
image: RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: asset.thumbhash ?? ""),
),
),
),

View File

@@ -8,6 +8,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/generated/intl_keys.g.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
class SettingsHeader {
String key = "";
@@ -20,7 +22,6 @@ class HeaderSettingsPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// final apiService = ref.watch(apiServiceProvider);
final headers = useState<List<SettingsHeader>>([]);
final setInitialHeaders = useState(false);
@@ -87,7 +88,7 @@ class HeaderSettingsPage extends HookConsumerWidget {
);
}
saveHeaders(List<SettingsHeader> headers) {
saveHeaders(List<SettingsHeader> headers) async {
final headersMap = {};
for (var header in headers) {
final key = header.key.trim();
@@ -98,7 +99,8 @@ class HeaderSettingsPage extends HookConsumerWidget {
}
var encoded = jsonEncode(headersMap);
Store.put(StoreKey.customHeaders, encoded);
await Store.put(StoreKey.customHeaders, encoded);
await networkApi.setRequestHeaders(ApiService.getRequestHeaders());
}
}

View File

@@ -11,7 +11,7 @@ import 'package:immich_mobile/providers/partner.provider.dart';
import 'package:immich_mobile/providers/search/people.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart';
import 'package:immich_mobile/widgets/common/immich_app_bar.dart';
@@ -221,12 +221,7 @@ class PeopleCollectionCard extends ConsumerWidget {
mainAxisSpacing: 8,
physics: const NeverScrollableScrollPhysics(),
children: people.take(4).map((person) {
return CircleAvatar(
backgroundImage: NetworkImage(
getFaceThumbnailUrl(person.id),
headers: ApiService.getRequestHeaders(),
),
);
return CircleAvatar(backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)));
}).toList(),
);
},

View File

@@ -5,8 +5,8 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/search/people.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
import 'package:immich_mobile/widgets/search/person_name_edit_form.dart';
@@ -17,7 +17,6 @@ class PeopleCollectionPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final people = ref.watch(getAllPeopleProvider);
final headers = ApiService.getRequestHeaders();
final formFocus = useFocusNode();
final ValueNotifier<String?> search = useState(null);
@@ -88,7 +87,7 @@ class PeopleCollectionPage extends HookConsumerWidget {
elevation: 3,
child: CircleAvatar(
maxRadius: isTablet ? 120 / 2 : 96 / 2,
backgroundImage: NetworkImage(getFaceThumbnailUrl(person.id), headers: headers),
backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)),
),
),
),

View File

@@ -1,5 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
@@ -10,9 +9,10 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/search/search_page_state.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@@ -125,13 +125,10 @@ class PlaceTile extends StatelessWidget {
title: Text(name, style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500)),
leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(20)),
child: CachedNetworkImage(
child: SizedBox(
width: 80,
height: 80,
fit: BoxFit.cover,
imageUrl: thumbnailUrl,
httpHeaders: ApiService.getRequestHeaders(),
errorWidget: (context, url, error) => const Icon(Icons.image_not_supported_outlined),
child: Thumbnail(imageProvider: RemoteImageProvider(url: thumbnailUrl)),
),
),
);

View File

@@ -4,8 +4,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/providers/search/people.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/widgets/search/person_name_edit_form.dart';
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
@@ -88,10 +88,7 @@ class PersonResultPage extends HookConsumerWidget {
padding: const EdgeInsets.only(left: 8.0, top: 24),
child: Row(
children: [
CircleAvatar(
radius: 36,
backgroundImage: NetworkImage(getFaceThumbnailUrl(personId), headers: ApiService.getRequestHeaders()),
),
CircleAvatar(radius: 36, backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(personId))),
Expanded(
child: Padding(padding: const EdgeInsets.only(left: 16.0, right: 16.0), child: buildTitleBlock()),
),

354
mobile/lib/platform/network_api.g.dart generated Normal file
View File

@@ -0,0 +1,354 @@
// 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".',
);
}
bool _deepEquals(Object? a, Object? b) {
if (a is List && b is List) {
return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
}
if (a is Map && b is Map) {
return a.length == b.length &&
a.entries.every(
(MapEntry<Object?, Object?> entry) =>
(b as Map<Object?, Object?>).containsKey(entry.key) && _deepEquals(entry.value, b[entry.key]),
);
}
return a == b;
}
class ClientCertData {
ClientCertData({required this.data, required this.password});
Uint8List data;
String password;
List<Object?> _toList() {
return <Object?>[data, password];
}
Object encode() {
return _toList();
}
static ClientCertData decode(Object result) {
result as List<Object?>;
return ClientCertData(data: result[0]! as Uint8List, password: result[1]! as String);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! ClientCertData || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(encode(), other.encode());
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => Object.hashAll(_toList());
}
class ClientCertPrompt {
ClientCertPrompt({required this.title, required this.message, required this.cancel, required this.confirm});
String title;
String message;
String cancel;
String confirm;
List<Object?> _toList() {
return <Object?>[title, message, cancel, confirm];
}
Object encode() {
return _toList();
}
static ClientCertPrompt decode(Object result) {
result as List<Object?>;
return ClientCertPrompt(
title: result[0]! as String,
message: result[1]! as String,
cancel: result[2]! as String,
confirm: result[3]! as String,
);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! ClientCertPrompt || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(encode(), other.encode());
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => Object.hashAll(_toList());
}
class WebSocketTaskResult {
WebSocketTaskResult({required this.taskPointer, this.taskProtocol});
int taskPointer;
String? taskProtocol;
List<Object?> _toList() {
return <Object?>[taskPointer, taskProtocol];
}
Object encode() {
return _toList();
}
static WebSocketTaskResult decode(Object result) {
result as List<Object?>;
return WebSocketTaskResult(taskPointer: result[0]! as int, taskProtocol: result[1] as String?);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! WebSocketTaskResult || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(encode(), other.encode());
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => Object.hashAll(_toList());
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
void writeValue(WriteBuffer buffer, Object? value) {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else if (value is ClientCertData) {
buffer.putUint8(129);
writeValue(buffer, value.encode());
} else if (value is ClientCertPrompt) {
buffer.putUint8(130);
writeValue(buffer, value.encode());
} else if (value is WebSocketTaskResult) {
buffer.putUint8(131);
writeValue(buffer, value.encode());
} else {
super.writeValue(buffer, value);
}
}
@override
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
case 129:
return ClientCertData.decode(readValue(buffer)!);
case 130:
return ClientCertPrompt.decode(readValue(buffer)!);
case 131:
return WebSocketTaskResult.decode(readValue(buffer)!);
default:
return super.readValueOfType(type, buffer);
}
}
}
class NetworkApi {
/// Constructor for [NetworkApi]. 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.
NetworkApi({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<void> addCertificate(ClientCertData clientData) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NetworkApi.addCertificate$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[clientData]);
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<ClientCertData> selectCertificate(ClientCertPrompt promptText) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NetworkApi.selectCertificate$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[promptText]);
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 ClientCertData?)!;
}
}
Future<void> removeCertificate() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NetworkApi.removeCertificate$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else {
return;
}
}
Future<int> getClientPointer() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else 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 int?)!;
}
}
/// iOS only - creates a WebSocket task and waits for connection to be established.
Future<WebSocketTaskResult> createWebSocketTask(String url, List<String>? protocols) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NetworkApi.createWebSocketTask$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, protocols]);
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 WebSocketTaskResult?)!;
}
}
Future<void> setRequestHeaders(Map<String, String> headers) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[headers]);
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;
}
}
}

View File

@@ -49,11 +49,7 @@ class RemoteImageApi {
final String pigeonVar_messageChannelSuffix;
Future<Map<String, int>?> requestImage(
String url, {
required Map<String, String> headers,
required int requestId,
}) async {
Future<Map<String, int>?> requestImage(String url, {required int requestId}) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
@@ -61,7 +57,7 @@ class RemoteImageApi {
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[url, headers, requestId]);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[url, requestId]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);

View File

@@ -12,8 +12,8 @@ import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/partner.provider.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
@@ -179,12 +179,7 @@ class _PeopleCollectionCard extends ConsumerWidget {
mainAxisSpacing: 8,
physics: const NeverScrollableScrollPhysics(),
children: people.take(4).map((person) {
return CircleAvatar(
backgroundImage: NetworkImage(
getFaceThumbnailUrl(person.id),
headers: ApiService.getRequestHeaders(),
),
);
return CircleAvatar(backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)));
}).toList(),
);
},

View File

@@ -4,8 +4,8 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/utils/people.utils.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
@@ -31,7 +31,6 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
@override
Widget build(BuildContext context) {
final people = ref.watch(driftGetAllPeopleProvider);
final headers = ApiService.getRequestHeaders();
return LayoutBuilder(
builder: (context, constraints) {
@@ -90,7 +89,7 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
elevation: 3,
child: CircleAvatar(
maxRadius: isTablet ? 100 / 2 : 96 / 2,
backgroundImage: NetworkImage(getFaceThumbnailUrl(person.id), headers: headers),
backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)),
),
),
),

View File

@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:ui';
import 'package:auto_route/auto_route.dart';
import 'package:cancellation_token_http/http.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -79,7 +78,7 @@ class DriftEditImagePage extends ConsumerWidget {
return;
}
await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset], CancellationToken());
await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset], Completer());
} catch (e) {
ImmichToast.show(
durationInSecond: 6,

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
@@ -41,16 +42,20 @@ class ShareActionButton extends ConsumerWidget {
return;
}
final cancelCompleter = Completer<void>();
const preparingDialog = _SharePreparingDialog();
await showDialog(
context: context,
builder: (BuildContext buildContext) {
ref.read(actionProvider.notifier).shareAssets(source, context).then((ActionResult result) {
ref.read(multiSelectProvider.notifier).reset();
if (!context.mounted) {
ref.read(actionProvider.notifier).shareAssets(source, context, cancelCompleter: cancelCompleter).then((
ActionResult result,
) {
if (cancelCompleter.isCompleted || !context.mounted) {
return;
}
ref.read(multiSelectProvider.notifier).reset();
if (!result.success) {
ImmichToast.show(
context: context,
@@ -64,11 +69,15 @@ class ShareActionButton extends ConsumerWidget {
});
// show a loading spinner with a "Preparing" message
return const _SharePreparingDialog();
return preparingDialog;
},
barrierDismissible: false,
useRootNavigator: false,
);
).then((_) {
if (!cancelCompleter.isCompleted) {
cancelCompleter.complete();
}
});
}
@override

View File

@@ -101,7 +101,7 @@ class _UploadProgressDialog extends ConsumerWidget {
actions: [
ImmichTextButton(
onPressed: () {
ref.read(manualUploadCancelTokenProvider)?.cancel();
ref.read(manualUploadCancelTokenProvider)?.complete();
Navigator.of(context).pop();
},
labelText: 'cancel'.t(context: context),

View File

@@ -451,21 +451,36 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}
void _onTimelineReloadEvent() {
totalAssets = ref.read(timelineServiceProvider).totalAssets;
final timelineService = ref.read(timelineServiceProvider);
totalAssets = timelineService.totalAssets;
if (totalAssets == 0) {
context.maybePop();
return;
}
var index = pageController.page?.round() ?? 0;
final currentAsset = ref.read(currentAssetNotifier);
if (currentAsset != null) {
final newIndex = timelineService.getIndex(currentAsset.heroTag);
if (newIndex != null && newIndex != index) {
index = newIndex;
pageController.jumpToPage(index);
}
}
if (index >= totalAssets) {
index = totalAssets - 1;
pageController.jumpToPage(index);
}
if (assetReloadRequested) {
assetReloadRequested = false;
_onAssetReloadEvent();
return;
_onAssetReloadEvent(index);
}
}
void _onAssetReloadEvent() async {
final index = pageController.page?.round() ?? 0;
void _onAssetReloadEvent(int index) async {
final timelineService = ref.read(timelineServiceProvider);
final newAsset = await timelineService.getAssetAsync(index);

View File

@@ -10,8 +10,8 @@ import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/utils/people.utils.dart';
@@ -108,8 +108,6 @@ class _PeopleAvatar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final headers = ApiService.getRequestHeaders();
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 96),
child: Padding(
@@ -127,7 +125,7 @@ class _PeopleAvatar extends StatelessWidget {
elevation: 3,
child: CircleAvatar(
maxRadius: imageSize / 2,
backgroundImage: NetworkImage(getFaceThumbnailUrl(person.id), headers: headers),
backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)),
),
),
),

View File

@@ -134,7 +134,7 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai
final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId;
final thumbhash = asset is RemoteAsset ? asset.thumbHash ?? "" : "";
return assetId != null ? RemoteThumbProvider(assetId: assetId, thumbhash: thumbhash) : null;
return assetId != null ? RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: thumbhash) : null;
}
bool _shouldUseLocalAsset(BaseAsset asset) =>

View File

@@ -6,54 +6,51 @@ 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/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> {
final String assetId;
final String thumbhash;
class RemoteImageProvider extends CancellableImageProvider<RemoteImageProvider>
with CancellableImageProviderMixin<RemoteImageProvider> {
final String url;
RemoteThumbProvider({required this.assetId, required this.thumbhash});
RemoteImageProvider({required this.url});
RemoteImageProvider.thumbnail({required String assetId, required String thumbhash})
: url = getThumbnailUrlForRemoteId(assetId, thumbhash: thumbhash);
@override
Future<RemoteThumbProvider> obtainKey(ImageConfiguration configuration) {
Future<RemoteImageProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(RemoteThumbProvider key, ImageDecoderCallback decode) {
ImageStreamCompleter loadImage(RemoteImageProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),
DiagnosticsProperty<String>('URL', key.url),
],
onDispose: cancel,
);
}
Stream<ImageInfo> _codec(RemoteThumbProvider key, ImageDecoderCallback decode) {
final request = this.request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId, thumbhash: key.thumbhash),
headers: ApiService.getRequestHeaders(),
);
Stream<ImageInfo> _codec(RemoteImageProvider key, ImageDecoderCallback decode) {
final request = this.request = RemoteImageRequest(uri: key.url);
return loadRequest(request, decode);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is RemoteThumbProvider) {
return assetId == other.assetId && thumbhash == other.thumbhash;
if (other is RemoteImageProvider) {
return url == other.url;
}
return false;
}
@override
int get hashCode => assetId.hashCode ^ thumbhash.hashCode;
int get hashCode => url.hashCode;
}
class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider>
@@ -73,7 +70,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
initialImage: getInitialImage(RemoteThumbProvider(assetId: key.assetId, thumbhash: key.thumbhash)),
initialImage: getInitialImage(RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash)),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),
@@ -90,10 +87,8 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
return;
}
final headers = ApiService.getRequestHeaders();
final previewRequest = request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
headers: headers,
);
yield* loadRequest(previewRequest, decode);
@@ -106,7 +101,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
return;
}
final originalRequest = request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId), headers: headers);
final originalRequest = request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId));
yield* loadRequest(originalRequest, decode);
}

View File

@@ -27,7 +27,7 @@ class Thumbnail extends StatefulWidget {
this.fit = BoxFit.cover,
Size size = kThumbnailResolution,
super.key,
}) : imageProvider = RemoteThumbProvider(assetId: remoteId, thumbhash: thumbhash),
}) : imageProvider = RemoteImageProvider.thumbnail(assetId: remoteId, thumbhash: thumbhash),
thumbhashProvider = null;
Thumbnail.fromAsset({

View File

@@ -1,10 +1,9 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
class PartnerUserAvatar extends StatelessWidget {
const PartnerUserAvatar({super.key, required this.partner});
@@ -18,11 +17,7 @@ class PartnerUserAvatar extends StatelessWidget {
return CircleAvatar(
radius: 16,
backgroundColor: context.primaryColor.withAlpha(50),
foregroundImage: CachedNetworkImageProvider(
url,
headers: ApiService.getRequestHeaders(),
cacheKey: "user-${partner.id}-profile",
),
foregroundImage: RemoteImageProvider(url: url),
// silence errors if user has no profile image, use initials as fallback
onForegroundImageError: (exception, stackTrace) {},
child: Text(nameFirstLetter.toUpperCase()),

View File

@@ -8,6 +8,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/models/auth/auth_state.model.dart';
import 'package:immich_mobile/models/auth/login_response.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/auth.service.dart';
@@ -124,6 +125,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
Future<bool> saveAuthInfo({required String accessToken}) async {
await _apiService.setAccessToken(accessToken);
await networkApi.setRequestHeaders(ApiService.getRequestHeaders());
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final customHeaders = Store.tryGet(StoreKey.customHeaders);

View File

@@ -1,4 +1,5 @@
import 'package:cancellation_token_http/http.dart';
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
/// Tracks per-asset upload progress.
@@ -30,4 +31,4 @@ final assetUploadProgressProvider = NotifierProvider<AssetUploadProgressNotifier
AssetUploadProgressNotifier.new,
);
final manualUploadCancelTokenProvider = StateProvider<CancellationToken?>((ref) => null);
final manualUploadCancelTokenProvider = StateProvider<Completer?>((ref) => null);

View File

@@ -1,6 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
@@ -68,7 +68,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
progressInFileSpeeds: const [],
progressInFileSpeedUpdateTime: DateTime.now(),
progressInFileSpeedUpdateSentBytes: 0,
cancelToken: CancellationToken(),
cancelToken: Completer(),
autoBackup: Store.get(StoreKey.autoBackup, false),
backgroundBackup: Store.get(StoreKey.backgroundBackup, false),
backupRequireWifi: Store.get(StoreKey.backupRequireWifi, true),
@@ -454,7 +454,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
// Perform Backup
state = state.copyWith(cancelToken: CancellationToken());
state = state.copyWith(cancelToken: Completer());
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
@@ -494,7 +494,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
if (state.backupProgress != BackUpProgressEnum.inProgress) {
notifyBackgroundServiceCanRun();
}
state.cancelToken.cancel();
state.cancelToken.complete();
state = state.copyWith(
backupProgress: BackUpProgressEnum.idle,
progressInPercentage: 0.0,

View File

@@ -1,6 +1,5 @@
import 'dart:async';
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:logging/logging.dart';
@@ -109,7 +108,7 @@ class DriftBackupState {
final BackupError error;
final Map<String, DriftUploadStatus> uploadItems;
final CancellationToken? cancelToken;
final Completer? cancelToken;
final Map<String, double> iCloudDownloadProgress;
@@ -133,7 +132,7 @@ class DriftBackupState {
bool? isSyncing,
BackupError? error,
Map<String, DriftUploadStatus>? uploadItems,
CancellationToken? cancelToken,
Completer? cancelToken,
Map<String, double>? iCloudDownloadProgress,
}) {
return DriftBackupState(
@@ -266,7 +265,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
state = state.copyWith(error: BackupError.none);
final cancelToken = CancellationToken();
final cancelToken = Completer();
state = state.copyWith(cancelToken: cancelToken);
return _foregroundUploadService.uploadCandidates(
@@ -282,7 +281,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
Future<void> stopForegroundBackup() async {
state.cancelToken?.cancel();
state.cancelToken?.complete();
_uploadSpeedManager.clear();
state = state.copyWith(cancelToken: null, uploadItems: {}, iCloudDownloadProgress: {});
}

View File

@@ -1,7 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/widgets.dart';
@@ -65,7 +64,7 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
progressInFileSpeeds: const [],
progressInFileSpeedUpdateTime: DateTime.now(),
progressInFileSpeedUpdateSentBytes: 0,
cancelToken: CancellationToken(),
cancelToken: Completer(),
currentUploadAsset: CurrentUploadAsset(
id: '...',
fileCreatedAt: DateTime.parse('2020-10-04'),
@@ -236,7 +235,7 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
fileName: '...',
fileType: '...',
),
cancelToken: CancellationToken(),
cancelToken: Completer(),
);
// Reset Error List
ref.watch(errorBackupListProvider.notifier).empty();
@@ -273,14 +272,14 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
);
// User cancelled upload
if (!ok && state.cancelToken.isCancelled) {
if (!ok && state.cancelToken.isCompleted) {
await _localNotificationService.showOrUpdateManualUploadStatus(
"backup_manual_title".tr(),
"backup_manual_cancelled".tr(),
presentBanner: true,
);
hasErrors = true;
} else if (state.successfulUploads == 0 || (!ok && !state.cancelToken.isCancelled)) {
} else if (state.successfulUploads == 0 || (!ok && !state.cancelToken.isCompleted)) {
await _localNotificationService.showOrUpdateManualUploadStatus(
"backup_manual_title".tr(),
"failed".tr(),
@@ -324,7 +323,7 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
_backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) {
_backupProvider.notifyBackgroundServiceCanRun();
}
state.cancelToken.cancel();
state.cancelToken.complete();
if (_backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) {
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
}

View File

@@ -1,94 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart' show ThumbnailSize;
/// The local image provider for an asset
class ImmichLocalImageProvider extends ImageProvider<ImmichLocalImageProvider> {
final Asset asset;
// only used for videos
final double width;
final double height;
final Logger log = Logger('ImmichLocalImageProvider');
ImmichLocalImageProvider({required this.asset, required this.width, required this.height})
: assert(asset.local != null, 'Only usable when asset.local is set');
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
/// that describes the precise image to load.
@override
Future<ImmichLocalImageProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(ImmichLocalImageProvider key, ImageDecoderCallback decode) {
final chunkEvents = StreamController<ImageChunkEvent>();
return MultiImageStreamCompleter(
codec: _codec(key.asset, decode, chunkEvents),
scale: 1.0,
chunkEvents: chunkEvents.stream,
informationCollector: () sync* {
yield ErrorDescription(asset.fileName);
},
);
}
// Streams in each stage of the image as we ask for it
Stream<ui.Codec> _codec(
Asset asset,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
) async* {
try {
final local = asset.local;
if (local == null) {
throw StateError('Asset ${asset.fileName} has no local data');
}
switch (asset.type) {
case AssetType.image:
final File? file = await local.originFile;
if (file == null) {
throw StateError("Opening file for asset ${asset.fileName} failed");
}
final buffer = await ui.ImmutableBuffer.fromFilePath(file.path);
yield await decode(buffer);
break;
case AssetType.video:
final size = ThumbnailSize(width.ceil(), height.ceil());
final thumbBytes = await local.thumbnailDataWithSize(size);
if (thumbBytes == null) {
throw StateError("Failed to load preview for ${asset.fileName}");
}
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
yield await decode(buffer);
break;
default:
throw StateError('Unsupported asset type ${asset.type}');
}
} catch (error, stack) {
log.severe('Error loading local image ${asset.fileName}', error, stack);
} finally {
unawaited(chunkEvents.close());
}
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is ImmichLocalImageProvider) {
return asset.id == other.asset.id && asset.localId == other.asset.localId;
}
return false;
}
@override
int get hashCode => Object.hash(asset.id, asset.localId);
}

View File

@@ -1,88 +0,0 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:photo_manager/photo_manager.dart' show ThumbnailSize;
import 'package:logging/logging.dart';
/// The local image provider for an asset
/// Only viable
class ImmichLocalThumbnailProvider extends ImageProvider<ImmichLocalThumbnailProvider> {
final Asset asset;
final int height;
final int width;
final CacheManager? cacheManager;
final Logger log = Logger("ImmichLocalThumbnailProvider");
final String? userId;
ImmichLocalThumbnailProvider({
required this.asset,
this.height = 256,
this.width = 256,
this.cacheManager,
this.userId,
}) : assert(asset.local != null, 'Only usable when asset.local is set');
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
/// that describes the precise image to load.
@override
Future<ImmichLocalThumbnailProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(ImmichLocalThumbnailProvider key, ImageDecoderCallback decode) {
final cache = cacheManager ?? ThumbnailImageCacheManager();
return MultiImageStreamCompleter(
codec: _codec(key.asset, cache, decode),
scale: 1.0,
informationCollector: () sync* {
yield ErrorDescription(key.asset.fileName);
},
);
}
// Streams in each stage of the image as we ask for it
Stream<ui.Codec> _codec(Asset assetData, CacheManager cache, ImageDecoderCallback decode) async* {
final cacheKey = '$userId${assetData.localId}${assetData.checksum}$width$height';
final fileFromCache = await cache.getFileFromCache(cacheKey);
if (fileFromCache != null) {
try {
final buffer = await ui.ImmutableBuffer.fromFilePath(fileFromCache.file.path);
final codec = await decode(buffer);
yield codec;
return;
} catch (error) {
log.severe('Found thumbnail in cache, but loading it failed', error);
}
}
final thumbnailBytes = await assetData.local?.thumbnailDataWithSize(ThumbnailSize(width, height), quality: 80);
if (thumbnailBytes == null) {
throw StateError("Loading thumb for local photo ${assetData.fileName} failed");
}
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbnailBytes);
final codec = await decode(buffer);
yield codec;
await cache.putFile(cacheKey, thumbnailBytes);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is ImmichLocalThumbnailProvider) {
return asset.id == other.asset.id && asset.localId == other.asset.localId;
}
return false;
}
@override
int get hashCode => Object.hash(asset.id, asset.localId);
}

View File

@@ -1,82 +0,0 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/providers/image/cache/image_loader.dart';
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
import 'package:openapi/api.dart' as api;
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
/// The remote image provider for full size remote images
class ImmichRemoteImageProvider extends ImageProvider<ImmichRemoteImageProvider> {
/// The [Asset.remoteId] of the asset to fetch
final String assetId;
/// The image cache manager
final CacheManager? cacheManager;
const ImmichRemoteImageProvider({required this.assetId, this.cacheManager});
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
/// that describes the precise image to load.
@override
Future<ImmichRemoteImageProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(ImmichRemoteImageProvider key, ImageDecoderCallback decode) {
final cache = cacheManager ?? RemoteImageCacheManager();
final chunkEvents = StreamController<ImageChunkEvent>();
return MultiImageStreamCompleter(
codec: _codec(key, cache, decode, chunkEvents),
scale: 1.0,
chunkEvents: chunkEvents.stream,
);
}
/// Whether to show the original file or load a compressed version
bool get _useOriginal => Store.get(AppSettingsEnum.loadOriginal.storeKey, AppSettingsEnum.loadOriginal.defaultValue);
// Streams in each stage of the image as we ask for it
Stream<ui.Codec> _codec(
ImmichRemoteImageProvider key,
CacheManager cache,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
) async* {
// Load the higher resolution version of the image
final url = getThumbnailUrlForRemoteId(key.assetId, type: api.AssetMediaSize.preview);
final codec = await ImageLoader.loadImageFromCache(url, cache: cache, decode: decode, chunkEvents: chunkEvents);
yield codec;
// Load the final remote image
if (_useOriginal) {
// Load the original image
final url = getOriginalUrlForRemoteId(key.assetId);
final codec = await ImageLoader.loadImageFromCache(url, cache: cache, decode: decode, chunkEvents: chunkEvents);
yield codec;
}
await chunkEvents.close();
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is ImmichRemoteImageProvider) {
return assetId == other.assetId;
}
return false;
}
@override
int get hashCode => assetId.hashCode;
}

View File

@@ -1,61 +0,0 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/providers/image/cache/image_loader.dart';
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
import 'package:openapi/api.dart' as api;
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
/// The remote image provider
class ImmichRemoteThumbnailProvider extends ImageProvider<ImmichRemoteThumbnailProvider> {
/// The [Asset.remoteId] of the asset to fetch
final String assetId;
final int? height;
final int? width;
/// The image cache manager
final CacheManager? cacheManager;
const ImmichRemoteThumbnailProvider({required this.assetId, this.height, this.width, this.cacheManager});
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
/// that describes the precise image to load.
@override
Future<ImmichRemoteThumbnailProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(ImmichRemoteThumbnailProvider key, ImageDecoderCallback decode) {
final cache = cacheManager ?? ThumbnailImageCacheManager();
return MultiImageStreamCompleter(codec: _codec(key, cache, decode), scale: 1.0);
}
// Streams in each stage of the image as we ask for it
Stream<ui.Codec> _codec(ImmichRemoteThumbnailProvider key, CacheManager cache, ImageDecoderCallback decode) async* {
// Load a preview to the chunk events
final preview = getThumbnailUrlForRemoteId(key.assetId, type: api.AssetMediaSize.thumbnail);
yield await ImageLoader.loadImageFromCache(preview, cache: cache, decode: decode);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is ImmichRemoteThumbnailProvider) {
return assetId == other.assetId;
}
return false;
}
@override
int get hashCode => assetId.hashCode;
}

View File

@@ -2,7 +2,6 @@ import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:background_downloader/background_downloader.dart';
import 'package:cancellation_token_http/http.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -405,11 +404,15 @@ class ActionNotifier extends Notifier<void> {
}
}
Future<ActionResult> shareAssets(ActionSource source, BuildContext context) async {
Future<ActionResult> shareAssets(
ActionSource source,
BuildContext context, {
Completer<void>? cancelCompleter,
}) async {
final ids = _getAssets(source).toList(growable: false);
try {
await _service.shareAssets(ids, context);
await _service.shareAssets(ids, context, cancelCompleter: cancelCompleter);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to share assets', error, stack);
@@ -433,7 +436,7 @@ class ActionNotifier extends Notifier<void> {
final assetsToUpload = assets ?? _getAssets(source).whereType<LocalAsset>().toList();
final progressNotifier = ref.read(assetUploadProgressProvider.notifier);
final cancelToken = CancellationToken();
final cancelToken = Completer();
ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken;
// Initialize progress for all assets

View File

@@ -5,6 +5,7 @@ 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/local_image_api.g.dart';
import 'package:immich_mobile/platform/network_api.g.dart';
import 'package:immich_mobile/platform/remote_image_api.g.dart';
final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
@@ -20,3 +21,5 @@ final connectivityApiProvider = Provider<ConnectivityApi>((_) => ConnectivityApi
final localImageApi = LocalImageApi();
final remoteImageApi = RemoteImageApi();
final networkApi = NetworkApi();

View File

@@ -1,18 +1,17 @@
import 'dart:async';
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/models/server_info/server_version.model.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/sync.service.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/utils/debug_print.dart';
@@ -99,11 +98,6 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
if (authenticationState.isAuthenticated) {
try {
final endpoint = Uri.parse(Store.get(StoreKey.serverEndpoint));
final headers = ApiService.getRequestHeaders();
if (endpoint.userInfo.isNotEmpty) {
headers["Authorization"] = "Basic ${base64.encode(utf8.encode(endpoint.userInfo))}";
}
dPrint(() => "Attempting to connect to websocket");
// Configure socket transports must be specified
Socket socket = io(
@@ -111,11 +105,11 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
OptionBuilder()
.setPath("${endpoint.path}/socket.io")
.setTransports(['websocket'])
.setWebSocketConnector(NetworkRepository.createWebSocket)
.enableReconnection()
.enableForceNew()
.enableForceNewConnection()
.enableAutoConnect()
.setExtraHeaders(headers)
.build(),
);

View File

@@ -23,7 +23,6 @@ final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.
class AssetMediaRepository {
final AssetApiRepository _assetApiRepository;
static final Logger _log = Logger("AssetMediaRepository");
const AssetMediaRepository(this._assetApiRepository);
@@ -58,6 +57,7 @@ class AssetMediaRepository {
static asset_entity.Asset? toAsset(AssetEntity? local) {
if (local == null) return null;
final asset_entity.Asset asset = asset_entity.Asset(
checksum: "",
localId: local.id,
@@ -72,19 +72,21 @@ class AssetMediaRepository {
height: local.height,
isFavorite: local.isFavorite,
);
if (asset.fileCreatedAt.year == 1970) {
asset.fileCreatedAt = asset.fileModifiedAt;
}
if (local.latitude != null) {
asset.exifInfo = ExifInfo(latitude: local.latitude, longitude: local.longitude);
}
asset.local = local;
return asset;
}
Future<String?> getOriginalFilename(String id) async {
final entity = await AssetEntity.fromId(id);
if (entity == null) {
return null;
}
@@ -101,12 +103,31 @@ class AssetMediaRepository {
}
}
/// Deletes temporary files in parallel
Future<void> _cleanupTempFiles(List<File> tempFiles) async {
await Future.wait(
tempFiles.map((file) async {
try {
await file.delete();
} catch (e) {
_log.warning("Failed to delete temporary file: ${file.path}", e);
}
}),
);
}
// TODO: make this more efficient
Future<int> shareAssets(List<BaseAsset> assets, BuildContext context) async {
Future<int> shareAssets(List<BaseAsset> assets, BuildContext context, {Completer<void>? cancelCompleter}) async {
final downloadedXFiles = <XFile>[];
final tempFiles = <File>[];
for (var asset in assets) {
if (cancelCompleter != null && cancelCompleter.isCompleted) {
// if cancelled, delete any temp files created so far
await _cleanupTempFiles(tempFiles);
return 0;
}
final localId = (asset is LocalAsset)
? asset.id
: asset is RemoteAsset
@@ -146,6 +167,11 @@ class AssetMediaRepository {
return 0;
}
if (cancelCompleter != null && cancelCompleter.isCompleted) {
await _cleanupTempFiles(tempFiles);
return 0;
}
// we dont want to await the share result since the
// "preparing" dialog will not disappear until
final size = context.sizeData;
@@ -154,13 +180,7 @@ class AssetMediaRepository {
downloadedXFiles,
sharePositionOrigin: Rect.fromPoints(Offset.zero, Offset(size.width / 3, size.height)),
).then((result) async {
for (var file in tempFiles) {
try {
await file.delete();
} catch (e) {
_log.warning("Failed to delete temporary file: ${file.path}", e);
}
}
await _cleanupTempFiles(tempFiles);
}),
);

View File

@@ -3,21 +3,14 @@ import 'dart:convert';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:cancellation_token_http/http.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:logging/logging.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/utils/debug_print.dart';
class UploadTaskWithFile {
final File file;
final UploadTask task;
const UploadTaskWithFile({required this.file, required this.task});
}
final uploadRepositoryProvider = Provider((ref) => UploadRepository());
class UploadRepository {
@@ -100,23 +93,27 @@ class UploadRepository {
required Map<String, String> headers,
required Map<String, String> fields,
required Client httpClient,
required CancellationToken cancelToken,
required void Function(int bytes, int totalBytes) onProgress,
required Completer cancelToken,
void Function(int bytes, int totalBytes)? onProgress,
required String logContext,
}) async {
final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
final baseRequest = ProgressMultipartRequest(
'POST',
Uri.parse('$savedEndpoint/assets'),
abortTrigger: cancelToken.future,
onProgress: onProgress,
);
try {
final fileStream = file.openRead();
final assetRawUploadData = MultipartFile("assetData", fileStream, file.lengthSync(), filename: originalFileName);
final baseRequest = _CustomMultipartRequest('POST', Uri.parse('$savedEndpoint/assets'), onProgress: onProgress);
baseRequest.headers.addAll(headers);
baseRequest.fields.addAll(fields);
baseRequest.files.add(assetRawUploadData);
final response = await httpClient.send(baseRequest, cancellationToken: cancelToken);
final response = await httpClient.send(baseRequest);
final responseBodyString = await response.stream.bytesToString();
if (![200, 201].contains(response.statusCode)) {
@@ -145,7 +142,7 @@ class UploadRepository {
} catch (e) {
return UploadResult.error(errorMessage: 'Failed to parse server response');
}
} on CancelledException {
} on RequestAbortedException {
logger.warning("Upload $logContext was cancelled");
return UploadResult.cancelled();
} catch (error, stackTrace) {
@@ -155,6 +152,34 @@ class UploadRepository {
}
}
class ProgressMultipartRequest extends MultipartRequest with Abortable {
ProgressMultipartRequest(super.method, super.url, {this.abortTrigger, this.onProgress});
@override
final Future<void>? abortTrigger;
final void Function(int bytes, int totalBytes)? onProgress;
@override
ByteStream finalize() {
final byteStream = super.finalize();
if (onProgress == null) return byteStream;
final total = contentLength;
var bytes = 0;
final stream = byteStream.transform(
StreamTransformer.fromHandlers(
handleData: (List<int> data, EventSink<List<int>> sink) {
bytes += data.length;
onProgress!(bytes, total);
sink.add(data);
},
),
);
return ByteStream(stream);
}
}
class UploadResult {
final bool isSuccess;
final bool isCancelled;
@@ -182,26 +207,3 @@ class UploadResult {
return const UploadResult(isSuccess: false, isCancelled: true);
}
}
class _CustomMultipartRequest extends MultipartRequest {
_CustomMultipartRequest(super.method, super.url, {required this.onProgress});
final void Function(int bytes, int totalBytes) onProgress;
@override
ByteStream finalize() {
final byteStream = super.finalize();
final total = contentLength;
var bytes = 0;
final t = StreamTransformer.fromHandlers(
handleData: (List<int> data, EventSink<List<int>> sink) {
bytes += data.length;
onProgress.call(bytes, total);
sink.add(data);
},
);
final stream = byteStream.transform(t);
return ByteStream(stream);
}
}

View File

@@ -232,8 +232,8 @@ class ActionService {
await _assetApiRepository.unStack(stackIds);
}
Future<int> shareAssets(List<BaseAsset> assets, BuildContext context) {
return _assetMediaRepository.shareAssets(assets, context);
Future<int> shareAssets(List<BaseAsset> assets, BuildContext context, {Completer<void>? cancelCompleter}) {
return _assetMediaRepository.shareAssets(assets, context, cancelCompleter: cancelCompleter);
}
Future<List<bool>> downloadAll(List<RemoteAsset> assets) {

View File

@@ -3,12 +3,11 @@ import 'dart:convert';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/utils/user_agent.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
@@ -50,7 +49,7 @@ class ApiService implements Authentication {
setEndpoint(String endpoint) {
_apiClient = ApiClient(basePath: endpoint, authentication: this);
_setUserAgentHeader();
_apiClient.client = NetworkRepository.client;
if (_accessToken != null) {
setAccessToken(_accessToken!);
}
@@ -76,11 +75,6 @@ class ApiService implements Authentication {
sessionsApi = SessionsApi(_apiClient);
}
Future<void> _setUserAgentHeader() async {
final userAgent = await getUserAgentString();
_apiClient.addDefaultHeader('User-Agent', userAgent);
}
Future<String> resolveAndSetEndpoint(String serverUrl) async {
final endpoint = await resolveEndpoint(serverUrl);
setEndpoint(endpoint);
@@ -134,14 +128,9 @@ class ApiService implements Authentication {
}
Future<String> _getWellKnownEndpoint(String baseUrl) async {
final Client client = Client();
try {
var headers = {"Accept": "application/json"};
headers.addAll(getRequestHeaders());
final res = await client
.get(Uri.parse("$baseUrl/.well-known/immich"), headers: headers)
final res = await NetworkRepository.client
.get(Uri.parse("$baseUrl/.well-known/immich"))
.timeout(const Duration(seconds: 5));
if (res.statusCode == 200) {
@@ -205,10 +194,7 @@ class ApiService implements Authentication {
@override
Future<void> applyToParams(List<QueryParam> queryParams, Map<String, String> headerParams) {
return Future<void>(() {
var headers = ApiService.getRequestHeaders();
headerParams.addAll(headers);
});
return Future.value();
}
ApiClient get apiClient => _apiClient;

View File

@@ -1,10 +1,10 @@
import 'dart:async';
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/models/auth/login_response.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
@@ -64,27 +64,16 @@ class AuthService {
}
Future<bool> validateAuxilaryServerUrl(String url) async {
final httpclient = HttpClient();
bool isValid = false;
try {
final uri = Uri.parse('$url/users/me');
final request = await httpclient.getUrl(uri);
// add auth token + any configured custom headers
final customHeaders = ApiService.getRequestHeaders();
customHeaders.forEach((key, value) {
request.headers.add(key, value);
});
final response = await request.close();
final response = await NetworkRepository.client.get(uri);
if (response.statusCode == 200) {
isValid = true;
}
} catch (error) {
_log.severe("Error validating auxiliary endpoint", error);
} finally {
httpclient.close();
}
return isValid;

View File

@@ -4,7 +4,6 @@ import 'dart:io';
import 'dart:isolate';
import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities;
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
@@ -30,7 +29,6 @@ import 'package:immich_mobile/utils/backup_progress.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:path_provider_foundation/path_provider_foundation.dart';
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
@@ -43,7 +41,7 @@ class BackgroundService {
static const MethodChannel _backgroundChannel = MethodChannel('immich/backgroundChannel');
static const notifyInterval = Duration(milliseconds: 400);
bool _isBackgroundInitialized = false;
CancellationToken? _cancellationToken;
Completer? _cancellationToken;
bool _canceledBySystem = false;
int _wantsLockTime = 0;
bool _hasLock = false;
@@ -321,7 +319,7 @@ class BackgroundService {
}
case "systemStop":
_canceledBySystem = true;
_cancellationToken?.cancel();
_cancellationToken?.complete();
return true;
default:
dPrint(() => "Unknown method ${call.method}");
@@ -341,7 +339,6 @@ class BackgroundService {
],
);
HttpSSLOptions.apply();
await ref.read(apiServiceProvider).setAccessToken(Store.get(StoreKey.accessToken));
await ref.read(authServiceProvider).setOpenApiServiceEndpoint();
dPrint(() => "[BG UPLOAD] Using endpoint: ${ref.read(apiServiceProvider).apiClient.basePath}");
@@ -441,7 +438,7 @@ class BackgroundService {
),
);
_cancellationToken = CancellationToken();
_cancellationToken = Completer();
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
final bool ok = await backupService.backupAsset(
@@ -455,7 +452,7 @@ class BackgroundService {
isBackground: true,
);
if (!ok && !_cancellationToken!.isCancelled) {
if (!ok && !_cancellationToken!.isCompleted) {
unawaited(
_showErrorNotification(
title: "backup_background_service_error_title".tr(),

View File

@@ -2,14 +2,16 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:cancellation_token_http/http.dart' as http;
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
@@ -43,7 +45,6 @@ final backupServiceProvider = Provider(
);
class BackupService {
final httpClient = http.Client();
final ApiService _apiService;
final Logger _log = Logger("BackupService");
final AppSettingsService _appSetting;
@@ -233,7 +234,7 @@ class BackupService {
Future<bool> backupAsset(
Iterable<BackupCandidate> assets,
http.CancellationToken cancelToken, {
Completer cancelToken, {
bool isBackground = false,
PMProgressHandler? pmProgressHandler,
required void Function(SuccessUploadAsset result) onSuccess,
@@ -306,16 +307,17 @@ class BackupService {
}
final fileStream = file.openRead();
final assetRawUploadData = http.MultipartFile(
final assetRawUploadData = MultipartFile(
"assetData",
fileStream,
file.lengthSync(),
filename: originalFileName,
);
final baseRequest = MultipartRequest(
final baseRequest = ProgressMultipartRequest(
'POST',
Uri.parse('$savedEndpoint/assets'),
abortTrigger: cancelToken.future,
onProgress: ((bytes, totalBytes) => onProgress(bytes, totalBytes)),
);
@@ -348,7 +350,7 @@ class BackupService {
baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId;
}
final response = await httpClient.send(baseRequest, cancellationToken: cancelToken);
final response = await NetworkRepository.client.send(baseRequest);
final responseBody = jsonDecode(await response.stream.bytesToString());
@@ -398,7 +400,7 @@ class BackupService {
await _albumService.syncUploadAlbums(candidate.albumNames, [responseBody['id'] as String]);
}
}
} on http.CancelledException {
} on RequestAbortedException {
dPrint(() => "Backup was cancelled by the user");
anyErrors = true;
break;
@@ -429,26 +431,26 @@ class BackupService {
String originalFileName,
File? livePhotoVideoFile,
MultipartRequest baseRequest,
http.CancellationToken cancelToken,
Completer cancelToken,
) async {
if (livePhotoVideoFile == null) {
return null;
}
final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoVideoFile.path));
final fileStream = livePhotoVideoFile.openRead();
final livePhotoRawUploadData = http.MultipartFile(
final livePhotoRawUploadData = MultipartFile(
"assetData",
fileStream,
livePhotoVideoFile.lengthSync(),
filename: livePhotoTitle,
);
final livePhotoReq = MultipartRequest(baseRequest.method, baseRequest.url, onProgress: baseRequest.onProgress)
final livePhotoReq = ProgressMultipartRequest(baseRequest.method, baseRequest.url, abortTrigger: cancelToken.future)
..headers.addAll(baseRequest.headers)
..fields.addAll(baseRequest.fields);
livePhotoReq.files.add(livePhotoRawUploadData);
var response = await httpClient.send(livePhotoReq, cancellationToken: cancelToken);
var response = await NetworkRepository.client.send(livePhotoReq);
var responseBody = jsonDecode(await response.stream.bytesToString());
@@ -470,31 +472,3 @@ class BackupService {
AssetType.other => "OTHER",
};
}
class MultipartRequest extends http.MultipartRequest {
/// Creates a new [MultipartRequest].
MultipartRequest(super.method, super.url, {required this.onProgress});
final void Function(int bytes, int totalBytes) onProgress;
/// Freezes all mutable fields and returns a
/// single-subscription [http.ByteStream]
/// that will emit the request body.
@override
http.ByteStream finalize() {
final byteStream = super.finalize();
final total = contentLength;
var bytes = 0;
final t = StreamTransformer.fromHandlers(
handleData: (List<int> data, EventSink<List<int>> sink) {
bytes += data.length;
onProgress.call(bytes, total);
sink.add(data);
},
);
final stream = byteStream.transform(t);
return http.ByteStream(stream);
}
}

View File

@@ -2,7 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:http/http.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -12,6 +12,7 @@ import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/platform/connectivity_api.g.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
@@ -82,7 +83,7 @@ class ForegroundUploadService {
/// Bulk upload of backup candidates from selected albums
Future<void> uploadCandidates(
String userId,
CancellationToken cancelToken, {
Completer cancelToken, {
UploadCallbacks callbacks = const UploadCallbacks(),
bool useSequentialUpload = false,
}) async {
@@ -105,7 +106,7 @@ class ForegroundUploadService {
final requireWifi = _shouldRequireWiFi(asset);
return requireWifi && !hasWifi;
},
processItem: (asset, httpClient) => _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks),
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
);
}
}
@@ -113,37 +114,32 @@ class ForegroundUploadService {
/// Sequential upload - used for background isolate where concurrent HTTP clients may cause issues
Future<void> _uploadSequentially({
required List<LocalAsset> items,
required CancellationToken cancelToken,
required Completer cancelToken,
required bool hasWifi,
required UploadCallbacks callbacks,
}) async {
final httpClient = Client();
await _storageRepository.clearCache();
shouldAbortUpload = false;
try {
for (final asset in items) {
if (shouldAbortUpload || cancelToken.isCancelled) {
break;
}
final requireWifi = _shouldRequireWiFi(asset);
if (requireWifi && !hasWifi) {
_logger.warning('Skipping upload for ${asset.id} because it requires WiFi');
continue;
}
await _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks);
for (final asset in items) {
if (shouldAbortUpload || cancelToken.isCompleted) {
break;
}
} finally {
httpClient.close();
final requireWifi = _shouldRequireWiFi(asset);
if (requireWifi && !hasWifi) {
_logger.warning('Skipping upload for ${asset.id} because it requires WiFi');
continue;
}
await _uploadSingleAsset(asset, cancelToken, callbacks: callbacks);
}
}
/// Manually upload picked local assets
Future<void> uploadManual(
List<LocalAsset> localAssets,
CancellationToken cancelToken, {
Completer cancelToken, {
UploadCallbacks callbacks = const UploadCallbacks(),
}) async {
if (localAssets.isEmpty) {
@@ -153,14 +149,14 @@ class ForegroundUploadService {
await _executeWithWorkerPool<LocalAsset>(
items: localAssets,
cancelToken: cancelToken,
processItem: (asset, httpClient) => _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks),
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
);
}
/// Upload files from shared intent
Future<void> uploadShareIntent(
List<File> files, {
CancellationToken? cancelToken,
Completer? cancelToken,
void Function(String fileId, int bytes, int totalBytes)? onProgress,
void Function(String fileId)? onSuccess,
void Function(String fileId, String errorMessage)? onError,
@@ -168,19 +164,18 @@ class ForegroundUploadService {
if (files.isEmpty) {
return;
}
final effectiveCancelToken = cancelToken ?? CancellationToken();
final effectiveCancelToken = cancelToken ?? Completer();
await _executeWithWorkerPool<File>(
items: files,
cancelToken: effectiveCancelToken,
processItem: (file, httpClient) async {
processItem: (file) async {
final fileId = p.hash(file.path).toString();
final result = await _uploadSingleFile(
file,
deviceAssetId: fileId,
httpClient: httpClient,
httpClient: NetworkRepository.client,
cancelToken: effectiveCancelToken,
onProgress: (bytes, totalBytes) => onProgress?.call(fileId, bytes, totalBytes),
);
@@ -207,60 +202,47 @@ class ForegroundUploadService {
/// [concurrentWorkers] - Number of concurrent workers (default: 3)
Future<void> _executeWithWorkerPool<T>({
required List<T> items,
required CancellationToken cancelToken,
required Future<void> Function(T item, Client httpClient) processItem,
required Completer cancelToken,
required Future<void> Function(T item) processItem,
bool Function(T item)? shouldSkip,
int concurrentWorkers = 3,
}) async {
final httpClients = List.generate(concurrentWorkers, (_) => Client());
await _storageRepository.clearCache();
shouldAbortUpload = false;
try {
int currentIndex = 0;
int currentIndex = 0;
Future<void> worker(Client httpClient) async {
while (true) {
if (shouldAbortUpload || cancelToken.isCancelled) {
break;
}
final index = currentIndex;
if (index >= items.length) {
break;
}
currentIndex++;
final item = items[index];
if (shouldSkip?.call(item) ?? false) {
continue;
}
await processItem(item, httpClient);
Future<void> worker() async {
while (true) {
if (shouldAbortUpload || cancelToken.isCompleted) {
break;
}
}
final workerFutures = <Future<void>>[];
for (int i = 0; i < concurrentWorkers; i++) {
workerFutures.add(worker(httpClients[i]));
}
final index = currentIndex;
if (index >= items.length) {
break;
}
currentIndex++;
await Future.wait(workerFutures);
} finally {
for (final client in httpClients) {
client.close();
final item = items[index];
if (shouldSkip?.call(item) ?? false) {
continue;
}
await processItem(item);
}
}
final workerFutures = <Future<void>>[];
for (int i = 0; i < concurrentWorkers; i++) {
workerFutures.add(worker());
}
await Future.wait(workerFutures);
}
Future<void> _uploadSingleAsset(
LocalAsset asset,
Client httpClient,
CancellationToken cancelToken, {
required UploadCallbacks callbacks,
}) async {
Future<void> _uploadSingleAsset(LocalAsset asset, Completer cancelToken, {required UploadCallbacks callbacks}) async {
File? file;
File? livePhotoFile;
@@ -358,15 +340,17 @@ class ForegroundUploadService {
if (entity.isLivePhoto && livePhotoFile != null) {
final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path));
final onProgress = callbacks.onProgress;
final livePhotoResult = await _uploadRepository.uploadFile(
file: livePhotoFile,
originalFileName: livePhotoTitle,
headers: headers,
fields: fields,
httpClient: httpClient,
httpClient: NetworkRepository.client,
cancelToken: cancelToken,
onProgress: (bytes, totalBytes) =>
callbacks.onProgress?.call(asset.localId!, livePhotoTitle, bytes, totalBytes),
onProgress: onProgress != null
? (bytes, totalBytes) => onProgress(asset.localId!, livePhotoTitle, bytes, totalBytes)
: null,
logContext: 'livePhotoVideo[${asset.localId}]',
);
@@ -395,15 +379,17 @@ class ForegroundUploadService {
]);
}
final onProgress = callbacks.onProgress;
final result = await _uploadRepository.uploadFile(
file: file,
originalFileName: originalFileName,
headers: headers,
fields: fields,
httpClient: httpClient,
httpClient: NetworkRepository.client,
cancelToken: cancelToken,
onProgress: (bytes, totalBytes) =>
callbacks.onProgress?.call(asset.localId!, originalFileName, bytes, totalBytes),
onProgress: onProgress != null
? (bytes, totalBytes) => onProgress(asset.localId!, originalFileName, bytes, totalBytes)
: null,
logContext: 'asset[${asset.localId}]',
);
@@ -443,7 +429,7 @@ class ForegroundUploadService {
File file, {
required String deviceAssetId,
required Client httpClient,
required CancellationToken cancelToken,
required Completer cancelToken,
void Function(int bytes, int totalBytes)? onProgress,
}) async {
try {
@@ -471,7 +457,7 @@ class ForegroundUploadService {
fields: fields,
httpClient: httpClient,
cancelToken: cancelToken,
onProgress: onProgress ?? (_, __) {},
onProgress: onProgress,
logContext: 'shareIntent[$deviceAssetId]',
);
} catch (e) {

View File

@@ -27,17 +27,19 @@ import 'package:isar/isar.dart';
import 'package:path_provider/path_provider.dart';
void configureFileDownloaderNotifications() {
final fileName = 'file_name'.t(args: {'file_name': '{filename}'});
FileDownloader().configureNotificationForGroup(
kDownloadGroupImage,
running: TaskNotification('downloading_media'.t(), '${'file_name'.t()}: {filename}'),
complete: TaskNotification('download_finished'.t(), '${'file_name'.t()}: {filename}'),
running: TaskNotification('downloading_media'.t(), fileName),
complete: TaskNotification('download_finished'.t(), fileName),
progressBar: true,
);
FileDownloader().configureNotificationForGroup(
kDownloadGroupVideo,
running: TaskNotification('downloading_media'.t(), '${'file_name'.t()}: {filename}'),
complete: TaskNotification('download_finished'.t(), '${'file_name'.t()}: {filename}'),
running: TaskNotification('downloading_media'.t(), fileName),
complete: TaskNotification('download_finished'.t(), fileName),
progressBar: true,
);

View File

@@ -2,10 +2,6 @@ import 'package:flutter/painting.dart';
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
import 'package:immich_mobile/providers/image/immich_local_image_provider.dart';
import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart';
import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart';
import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart';
/// [ImageCache] that uses two caches for small and large images
/// so that a single large image does not evict all small images
@@ -39,14 +35,9 @@ final class CustomImageCache implements ImageCache {
}
/// Gets the cache for the given key
/// [_large] is used for [ImmichLocalImageProvider] and [ImmichRemoteImageProvider]
/// [_small] is used for [ImmichLocalThumbnailProvider] and [ImmichRemoteThumbnailProvider]
ImageCache _cacheForKey(Object key) {
return switch (key) {
ImmichLocalImageProvider() ||
ImmichRemoteImageProvider() ||
LocalFullImageProvider() ||
RemoteFullImageProvider() => _large,
LocalFullImageProvider() || RemoteFullImageProvider() => _large,
ThumbHashProvider() => _thumbhash,
_ => _small,
};

View File

@@ -1,61 +0,0 @@
import 'dart:io';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:logging/logging.dart';
class HttpSSLCertOverride extends HttpOverrides {
static final Logger _log = Logger("HttpSSLCertOverride");
final bool _allowSelfSignedSSLCert;
final String? _serverHost;
final SSLClientCertStoreVal? _clientCert;
late final SecurityContext? _ctxWithCert;
HttpSSLCertOverride(this._allowSelfSignedSSLCert, this._serverHost, this._clientCert) {
if (_clientCert != null) {
_ctxWithCert = SecurityContext(withTrustedRoots: true);
if (_ctxWithCert != null) {
setClientCert(_ctxWithCert, _clientCert);
} else {
_log.severe("Failed to create security context with client cert!");
}
} else {
_ctxWithCert = null;
}
}
static bool setClientCert(SecurityContext ctx, SSLClientCertStoreVal cert) {
try {
_log.info("Setting client certificate");
ctx.usePrivateKeyBytes(cert.data, password: cert.password);
ctx.useCertificateChainBytes(cert.data, password: cert.password);
} catch (e) {
_log.severe("Failed to set SSL client cert: $e");
return false;
}
return true;
}
@override
HttpClient createHttpClient(SecurityContext? context) {
if (context != null) {
if (_clientCert != null) {
setClientCert(context, _clientCert);
}
} else {
context = _ctxWithCert;
}
return super.createHttpClient(context)
..badCertificateCallback = (X509Certificate cert, String host, int port) {
if (_allowSelfSignedSSLCert) {
// Conduct server host checks if user is logged in to avoid making
// insecure SSL connections to services that are not the immich server.
if (_serverHost == null || _serverHost.contains(host)) {
return true;
}
}
_log.severe("Invalid SSL certificate for $host:$port");
return false;
};
}
}

View File

@@ -1,42 +0,0 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
import 'package:logging/logging.dart';
class HttpSSLOptions {
static const MethodChannel _channel = MethodChannel('immich/httpSSLOptions');
static void apply({bool applyNative = true}) {
AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert;
bool allowSelfSignedSSLCert = Store.get(setting.storeKey as StoreKey<bool>, setting.defaultValue);
_apply(allowSelfSignedSSLCert, applyNative: applyNative);
}
static void applyFromSettings(bool newValue) {
_apply(newValue);
}
static void _apply(bool allowSelfSignedSSLCert, {bool applyNative = true}) {
String? serverHost;
if (allowSelfSignedSSLCert && Store.tryGet(StoreKey.currentUser) != null) {
serverHost = Uri.parse(Store.tryGet(StoreKey.serverEndpoint) ?? "").host;
}
SSLClientCertStoreVal? clientCert = SSLClientCertStoreVal.load();
HttpOverrides.global = HttpSSLCertOverride(allowSelfSignedSSLCert, serverHost, clientCert);
if (applyNative && Platform.isAndroid) {
_channel
.invokeMethod("apply", [allowSelfSignedSSLCert, serverHost, clientCert?.data, clientCert?.password])
.onError<PlatformException>((e, _) {
final log = Logger("HttpSSLOptions");
log.severe('Failed to set SSL options', e.message);
});
}
}
}

View File

@@ -10,7 +10,6 @@ import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/wm_executor.dart';
import 'package:logging/logging.dart';
import 'package:worker_manager/worker_manager.dart';
@@ -54,7 +53,6 @@ Cancelable<T?> runInIsolateGentle<T>({
Logger log = Logger("IsolateLogger");
try {
HttpSSLOptions.apply(applyNative: false);
result = await computation(ref);
} on CanceledError {
log.warning("Computation cancelled ${debugLabel == null ? '' : ' for $debugLabel'}");

View File

@@ -23,6 +23,8 @@ import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/platform/network_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/debug_print.dart';
@@ -32,7 +34,7 @@ import 'package:isar/isar.dart';
// ignore: import_rule_photo_manager
import 'package:photo_manager/photo_manager.dart';
const int targetVersion = 20;
const int targetVersion = 21;
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
final hasVersion = Store.tryGet(StoreKey.version) != null;
@@ -91,6 +93,13 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
await _syncLocalAlbumIsIosSharedAlbum(drift);
}
if (version < 21) {
final certData = SSLClientCertStoreVal.load();
if (certData != null) {
await networkApi.addCertificate(ClientCertData(data: certData.data, password: certData.password ?? ""));
}
}
if (targetVersion >= 12) {
await Store.put(StoreKey.version, targetVersion);
return;

View File

@@ -4,8 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/datetime_extensions.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/providers/activity_service.provider.dart';
import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
@@ -102,7 +102,7 @@ class _ActivityAssetThumbnail extends StatelessWidget {
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(4)),
image: DecorationImage(
image: ImmichRemoteThumbnailProvider(assetId: assetId),
image: RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: ""),
fit: BoxFit.cover,
),
),

View File

@@ -4,9 +4,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/datetime_extensions.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/activity_service.provider.dart';
import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/activities/dismissible_activity.dart';
@@ -56,7 +56,7 @@ class CommentBubble extends ConsumerWidget {
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(10)),
child: Image(
image: ImmichRemoteThumbnailProvider(assetId: activity.assetId!),
image: RemoteImageProvider.thumbnail(assetId: activity.assetId!, thumbhash: ""),
fit: BoxFit.cover,
),
),

View File

@@ -1,12 +1,12 @@
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
@@ -32,15 +32,12 @@ class AlbumThumbnailListTile extends StatelessWidget {
}
buildAlbumThumbnail() {
return CachedNetworkImage(
return SizedBox(
width: cardSize,
height: cardSize,
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 200),
imageUrl: getAlbumThumbnailUrl(album, type: AssetMediaSize.thumbnail),
httpHeaders: ApiService.getRequestHeaders(),
cacheKey: getAlbumThumbNailCacheKey(album, type: AssetMediaSize.thumbnail),
errorWidget: (context, url, error) => const Icon(Icons.image_not_supported_outlined),
child: Thumbnail(
imageProvider: RemoteImageProvider(url: getAlbumThumbnailUrl(album, type: AssetMediaSize.thumbnail)),
),
);
}

View File

@@ -1,10 +1,11 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' as base_asset;
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/image/immich_local_image_provider.dart';
import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
import 'package:octo_image/octo_image.dart';
@@ -34,13 +35,21 @@ class ImmichImage extends StatelessWidget {
}
if (asset == null) {
return ImmichRemoteImageProvider(assetId: assetId!);
return RemoteFullImageProvider(assetId: assetId!, thumbhash: '', assetType: base_asset.AssetType.video);
}
if (useLocal(asset)) {
return ImmichLocalImageProvider(asset: asset, width: width, height: height);
return LocalFullImageProvider(
id: asset.localId!,
assetType: base_asset.AssetType.video,
size: Size(width, height),
);
} else {
return ImmichRemoteImageProvider(assetId: asset.remoteId!);
return RemoteFullImageProvider(
assetId: asset.remoteId!,
thumbhash: asset.thumbhash ?? '',
assetType: base_asset.AssetType.video,
);
}
}

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