mirror of
https://github.com/immich-app/immich.git
synced 2026-02-08 02:39:24 +03:00
Compare commits
37 Commits
feat/debug
...
feat/use-n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2498f315d3 | ||
|
|
03afe8fb3e | ||
|
|
e7b7fe8ed8 | ||
|
|
78a834408c | ||
|
|
378a068a29 | ||
|
|
e89d174bd0 | ||
|
|
3228dcb70b | ||
|
|
092ebe01a5 | ||
|
|
37e5968a7a | ||
|
|
cfc5ed5997 | ||
|
|
1b3c0e4f65 | ||
|
|
fd49d7d566 | ||
|
|
ad9f3cfa05 | ||
|
|
9d8efe2685 | ||
|
|
ed4d9abdae | ||
|
|
ac9f6921cc | ||
|
|
f0da875e37 | ||
|
|
b0e1a425b3 | ||
|
|
7211d80e5f | ||
|
|
92c79a7122 | ||
|
|
7580521a76 | ||
|
|
2dd3a764ae | ||
|
|
a42c08ed84 | ||
|
|
3c77c724c5 | ||
|
|
84b2979485 | ||
|
|
e9c2ca008a | ||
|
|
9c098109e0 | ||
|
|
27a2808470 | ||
|
|
0a8a65a45e | ||
|
|
2b6055e830 | ||
|
|
ba2dfa7df6 | ||
|
|
237ea3aedd | ||
|
|
810e9254f3 | ||
|
|
57e0835b46 | ||
|
|
e97030a7ae | ||
|
|
fdf06a91cc | ||
|
|
732303661b |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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**¹** |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
10
mobile/android/app/proguard-rules.pro
vendored
10
mobile/android/app/proguard-rules.pro
vendored
@@ -36,4 +36,12 @@
|
||||
##---------------End: proguard configuration for Gson ----------
|
||||
|
||||
# Keep all widget model classes and their fields for Gson
|
||||
-keep class app.alextran.immich.widget.model.** { *; }
|
||||
-keep class app.alextran.immich.widget.model.** { *; }
|
||||
|
||||
##---------------Begin: proguard configuration for ok_http JNI ----------
|
||||
# The ok_http Dart plugin accesses OkHttp and Okio classes via JNI
|
||||
# string-based reflection (JClass.forName), which R8 cannot trace.
|
||||
-keep class okhttp3.** { *; }
|
||||
-keep class okio.** { *; }
|
||||
-keep class com.example.ok_http.** { *; }
|
||||
##---------------End: proguard configuration for ok_http JNI ----------
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
|
||||
<application android:label="Immich" android:name=".ImmichApp" android:usesCleartextTraffic="true"
|
||||
android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="true"
|
||||
android:largeHeap="true" android:enableOnBackInvokedCallback="false" android:allowBackup="false">
|
||||
android:largeHeap="true" android:enableOnBackInvokedCallback="false" android:allowBackup="false"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
|
||||
<profileable android:shell="true" />
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
369
mobile/ios/Runner/Core/Network.g.swift
Normal file
369
mobile/ios/Runner/Core/Network.g.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
190
mobile/ios/Runner/Core/NetworkApiImpl.swift
Normal file
190
mobile/ios/Runner/Core/NetworkApiImpl.swift
Normal 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)
|
||||
}
|
||||
171
mobile/ios/Runner/Core/URLSessionManager.swift
Normal file
171
mobile/ios/Runner/Core/URLSessionManager.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
7
mobile/ios/Runner/Images/ImageProcessing.swift
Normal file
7
mobile/ios/Runner/Images/ImageProcessing.swift
Normal 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)
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>($),
|
||||
|
||||
@@ -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} =>
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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 ?? ""),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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
354
mobile/lib/platform/network_api.g.dart
generated
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
mobile/lib/platform/remote_image_api.g.dart
generated
8
mobile/lib/platform/remote_image_api.g.dart
generated
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
11
mobile/lib/utils/cache/custom_image_cache.dart
vendored
11
mobile/lib/utils/cache/custom_image_cache.dart
vendored
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'}");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user