Compare commits

..

1 Commits

Author SHA1 Message Date
renovate[bot] 4184fdc187 chore(deps): update node.js to v24.16.0 2026-06-03 04:47:19 +00:00
263 changed files with 4257 additions and 20357 deletions
-33
View File
@@ -4,7 +4,6 @@ on:
pull_request:
paths:
- 'open-api/**'
- 'mobile/lib/utils/openapi_patching.dart'
- '.github/workflows/check-openapi.yml'
concurrency:
@@ -30,35 +29,3 @@ jobs:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json
fail-on: ERR
check-mobile-patches:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ github.token }}
- name: Get packages
working-directory: ./mobile
run: flutter pub get
- name: Fetch base spec from main
run: |
curl -fsSL \
"https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json" \
-o /tmp/base-spec.json
- name: Check newly-required fields have a backward-compat patch
working-directory: ./mobile
env:
OPENAPI_BASE_SPEC: /tmp/base-spec.json
OPENAPI_REVISION_SPEC: ../open-api/immich-openapi-specs.json
run: flutter test test/openapi_patches_coverage.dart
+1 -1
View File
@@ -1 +1 @@
24.15.0
24.16.0
@@ -259,6 +259,17 @@ describe('/search', () => {
assets: [assetHeic],
}),
},
{
should: "should search city ('')",
deferred: () => ({
dto: {
city: '',
visibility: AssetVisibility.Timeline,
includeNull: true,
},
assets: [assetLast],
}),
},
{
should: 'should search city (null)',
deferred: () => ({
@@ -280,6 +291,18 @@ describe('/search', () => {
assets: [assetDensity],
}),
},
{
should: "should search state ('')",
deferred: () => ({
dto: {
state: '',
visibility: AssetVisibility.Timeline,
withExif: true,
includeNull: true,
},
assets: [assetLast, assetNotocactus],
}),
},
{
should: 'should search state (null)',
deferred: () => ({
@@ -301,6 +324,17 @@ describe('/search', () => {
assets: [assetFalcon],
}),
},
{
should: "should search country ('')",
deferred: () => ({
dto: {
country: '',
visibility: AssetVisibility.Timeline,
includeNull: true,
},
assets: [assetLast],
}),
},
{
should: 'should search country (null)',
deferred: () => ({
-16
View File
@@ -816,10 +816,6 @@ class TestFaceRecognition:
def test_recognition(self, cv_image: cv2.Mat, mocker: MockerFixture) -> None:
mocker.patch.object(FaceRecognizer, "load")
mocker.patch(
"immich_ml.models.facial_recognition.recognition.ort.get_available_providers",
return_value=["CPUExecutionProvider"],
)
face_recognizer = FaceRecognizer("buffalo_s", min_score=0.0, cache_dir="test_cache")
num_faces = 2
@@ -864,10 +860,6 @@ class TestFaceRecognition:
)
mocker.patch("immich_ml.models.base.InferenceModel.download")
mocker.patch("immich_ml.models.facial_recognition.recognition.ArcFaceONNX")
mocker.patch(
"immich_ml.models.facial_recognition.recognition.ort.get_available_providers",
return_value=["CPUExecutionProvider"],
)
ort_session.return_value.get_inputs.return_value = [SimpleNamespace(name="input.1", shape=(1, 3, 224, 224))]
ort_session.return_value.get_outputs.return_value = [SimpleNamespace(name="output.1", shape=(1, 800))]
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
@@ -902,10 +894,6 @@ class TestFaceRecognition:
)
mocker.patch("immich_ml.models.base.InferenceModel.download")
mocker.patch("immich_ml.models.facial_recognition.recognition.ArcFaceONNX")
mocker.patch(
"immich_ml.models.facial_recognition.recognition.ort.get_available_providers",
return_value=["CPUExecutionProvider"],
)
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
inputs = [SimpleNamespace(name="input.1", shape=("batch", 3, 224, 224))]
@@ -1008,10 +996,6 @@ class TestFaceRecognition:
def test_ignore_other_custom_max_batch_size(self, mocker: MockerFixture) -> None:
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(ocr=2))
mocker.patch(
"immich_ml.models.facial_recognition.recognition.ort.get_available_providers",
return_value=["CPUExecutionProvider"],
)
recognizer = FaceRecognizer("buffalo_l", cache_dir="test_cache")
+93 -28
View File
@@ -1,5 +1,74 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools."aqua:flutter/flutter"]]
version = "3.44.1"
backend = "aqua:flutter/flutter"
[tools."aqua:flutter/flutter"."platforms.linux-arm64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.1-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-arm64-musl"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.1-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.1-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-x64-musl"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.1-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.macos-arm64"]
checksum = "blake3:15069c982a30ca0189a83edb5627b69d91485ad94fb74d2de8585b43364e9e8e"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.44.1-stable.zip"
[tools."aqua:flutter/flutter"."platforms.macos-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_3.44.1-stable.zip"
[tools."aqua:flutter/flutter"."platforms.windows-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_3.44.1-stable.zip"
[[tools.flutter]]
version = "3.41.9-stable"
backend = "asdf:flutter"
[[tools."github:CQLabs/homebrew-dcm"]]
version = "1.37.0"
backend = "github:CQLabs/homebrew-dcm"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-arm64"]
checksum = "sha256:253da2512b149913dfe345bf9a62a79acb2d730f66e71162ba4a92dfc4224b82"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543838"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-arm64-musl"]
checksum = "sha256:253da2512b149913dfe345bf9a62a79acb2d730f66e71162ba4a92dfc4224b82"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543838"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64"]
checksum = "sha256:477e086d4099c12f21e5ccd83b005d5fb945dd4cac4fd127fd9a08d7649af1cf"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543797"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64-musl"]
checksum = "sha256:477e086d4099c12f21e5ccd83b005d5fb945dd4cac4fd127fd9a08d7649af1cf"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543797"
[tools."github:CQLabs/homebrew-dcm"."platforms.macos-arm64"]
checksum = "sha256:30bede64367d09067093cc57af6ec9496d7717898138ded5cb98a16ac8dd9d93"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-macos-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543757"
[tools."github:CQLabs/homebrew-dcm"."platforms.macos-x64"]
checksum = "sha256:e56cb99872be7445a4de1d37e5438ca70e3bcd83be7a2b9b385e3538881f8068"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-macos-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543727"
[tools."github:CQLabs/homebrew-dcm"."platforms.windows-x64"]
checksum = "sha256:f133470daa3fb0427f039b424392af7e917d7e7db6b556aa2a968ab0e31587da"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-windows-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543660"
[[tools."github:extism/cli"]]
version = "1.6.3"
backend = "github:extism/cli"
@@ -156,6 +225,30 @@ checksum = "sha256:b5e1d2a1ad3c03229ddc89823848f4a1c11f9c6402a51fa26f0aaa5f1d7a2
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-windows.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288925833"
[[tools.java]]
version = "21.0.2"
backend = "core:java"
[tools.java."platforms.linux-arm64"]
checksum = "sha256:08db1392a48d4eb5ea5315cf8f18b89dbaf36cda663ba882cf03c704c9257ec2"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-aarch64_bin.tar.gz"
[tools.java."platforms.linux-x64"]
checksum = "sha256:a2def047a73941e01a73739f92755f86b895811afb1f91243db214cff5bdac3f"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-x64_bin.tar.gz"
[tools.java."platforms.macos-arm64"]
checksum = "sha256:b3d588e16ec1e0ef9805d8a696591bd518a5cea62567da8f53b5ce32d11d22e4"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-aarch64_bin.tar.gz"
[tools.java."platforms.macos-x64"]
checksum = "sha256:8fd09e15dc406387a0aba70bf5d99692874e999bf9cd9208b452b5d76ac922d3"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-x64_bin.tar.gz"
[tools.java."platforms.windows-x64"]
checksum = "sha256:b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip"
[[tools.node]]
version = "24.15.0"
backend = "core:node"
@@ -228,34 +321,6 @@ url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.
version = "10.33.4"
backend = "aqua:pnpm/pnpm"
[tools.pnpm."platforms.linux-arm64"]
checksum = "sha256:d29649c7380b5cd522f574208fbd35335846686498f45004604d3f5b8658b5cb"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-linux-arm64"
[tools.pnpm."platforms.linux-arm64-musl"]
checksum = "sha256:d29649c7380b5cd522f574208fbd35335846686498f45004604d3f5b8658b5cb"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-linux-arm64"
[tools.pnpm."platforms.linux-x64"]
checksum = "sha256:ff1795595535a10d0dfe327303f3dd02377be141190b1f5756de68edde2cf813"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-linux-x64"
[tools.pnpm."platforms.linux-x64-musl"]
checksum = "sha256:ff1795595535a10d0dfe327303f3dd02377be141190b1f5756de68edde2cf813"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-linux-x64"
[tools.pnpm."platforms.macos-arm64"]
checksum = "sha256:7aae186a04e1ffaa0047d43cd07d68a98dec303304f28be52234ba955d26c671"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-macos-arm64"
[tools.pnpm."platforms.macos-x64"]
checksum = "sha256:3b0c97b9f794cdda293949a8ee0e0151ca08f512f4a832408386221c7c73eec6"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-macos-x64"
[tools.pnpm."platforms.windows-x64"]
checksum = "sha256:3268b2f29defe0dce8a3a26c0ef01488f0d4aa4872923173186ef618ab7d68ef"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-win-x64.exe"
[[tools.terragrunt]]
version = "1.0.3"
backend = "aqua:gruntwork-io/terragrunt"
+15 -1
View File
@@ -15,15 +15,29 @@ config_roots = [
]
[tools]
node = "24.15.0"
node = "24.16.0"
"aqua:flutter/flutter" = "3.44.1"
pnpm = "10.33.4"
terragrunt = "1.0.3"
opentofu = "1.11.6"
java = "21.0.2"
"npm:oazapfts" = "7.5.0"
"github:extism/cli" = "1.6.3"
"github:webassembly/binaryen" = "version_124"
"github:extism/js-pdk" = "1.6.0"
[tools."github:CQLabs/homebrew-dcm"]
version = "1.37.0"
bin = "dcm"
postinstall = "chmod +x \"$MISE_TOOL_INSTALL_PATH/dcm\" || true"
[tools."github:CQLabs/homebrew-dcm".platforms]
linux-x64 = { asset_pattern = "dcm-linux-x64-release.zip" }
linux-arm64 = { asset_pattern = "dcm-linux-arm-release.zip" }
macos-x64 = { asset_pattern = "dcm-macos-x64-release.zip" }
macos-arm64 = { asset_pattern = "dcm-macos-arm-release.zip" }
windows-x64 = { asset_pattern = "dcm-windows-release.zip" }
[tools."github:jellyfin/jellyfin-ffmpeg"]
version = "7.1.3-6"
@@ -89,20 +89,6 @@
<data android:mimeType="video/*" />
</intent-filter>
<!-- Allow Immich to act as an image viewer -->
<intent-filter android:label="View in Immich">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" android:mimeType="image/*" />
</intent-filter>
<!-- Allow Immich to act as a video viewer -->
<intent-filter android:label="View in Immich">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" android:mimeType="video/*" />
</intent-filter>
<!-- immich:// URL scheme handling -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
@@ -1,7 +1,6 @@
package app.alextran.immich
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.ext.SdkExtensions
import app.alextran.immich.background.BackgroundEngineLock
@@ -23,7 +22,6 @@ import app.alextran.immich.permission.PermissionApiImpl
import app.alextran.immich.sync.NativeSyncApi
import app.alextran.immich.sync.NativeSyncApiImpl26
import app.alextran.immich.sync.NativeSyncApiImpl30
import app.alextran.immich.viewintent.ViewIntentPlugin
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
@@ -33,11 +31,6 @@ class MainActivity : FlutterFragmentActivity() {
registerPlugins(this, flutterEngine)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
}
companion object {
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
HttpClientManager.initialize(ctx)
@@ -62,7 +55,6 @@ class MainActivity : FlutterFragmentActivity() {
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
flutterEngine.plugins.add(ViewIntentPlugin())
flutterEngine.plugins.add(backgroundEngineLockImpl)
flutterEngine.plugins.add(nativeSyncApiImpl)
flutterEngine.plugins.add(permissionApiImpl)
@@ -542,17 +542,16 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface NativeSyncApi {
fun shouldFullSync(callback: (Result<Boolean>) -> Unit)
fun getMediaChanges(callback: (Result<SyncDelta>) -> Unit)
fun shouldFullSync(): Boolean
fun getMediaChanges(): SyncDelta
fun checkpointSync()
fun clearSyncCheckpoint()
fun getAssetIdsForAlbum(albumId: String, callback: (Result<List<String>>) -> Unit)
fun getAlbums(callback: (Result<List<PlatformAlbum>>) -> Unit)
fun getAssetIdsForAlbum(albumId: String): List<String>
fun getAlbums(): List<PlatformAlbum>
fun getAssetsCountSince(albumId: String, timestamp: Long): Long
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?, callback: (Result<List<PlatformAsset>>) -> Unit)
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset>
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
fun cancelHashing()
fun cancelSync()
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit)
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
@@ -571,33 +570,27 @@ interface NativeSyncApi {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.shouldFullSync{ result: Result<Boolean> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
val wrapped: List<Any?> = try {
listOf(api.shouldFullSync())
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$separatedMessageChannelSuffix", codec)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.getMediaChanges{ result: Result<SyncDelta> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
val wrapped: List<Any?> = try {
listOf(api.getMediaChanges())
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
@@ -636,38 +629,32 @@ interface NativeSyncApi {
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$separatedMessageChannelSuffix", codec)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val albumIdArg = args[0] as String
api.getAssetIdsForAlbum(albumIdArg) { result: Result<List<String>> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
val wrapped: List<Any?> = try {
listOf(api.getAssetIdsForAlbum(albumIdArg))
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$separatedMessageChannelSuffix", codec)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.getAlbums{ result: Result<List<PlatformAlbum>> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
val wrapped: List<Any?> = try {
listOf(api.getAlbums())
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
@@ -692,21 +679,18 @@ interface NativeSyncApi {
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$separatedMessageChannelSuffix", codec)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val albumIdArg = args[0] as String
val updatedTimeCondArg = args[1] as Long?
api.getAssetsForAlbum(albumIdArg, updatedTimeCondArg) { result: Result<List<PlatformAsset>> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
val wrapped: List<Any?> = try {
listOf(api.getAssetsForAlbum(albumIdArg, updatedTimeCondArg))
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
@@ -749,22 +733,6 @@ interface NativeSyncApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelSync$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
api.cancelSync()
listOf(null)
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
@@ -4,11 +4,7 @@ import android.content.Context
class NativeSyncApiImpl26(context: Context) : NativeSyncApiImplBase(context), NativeSyncApi {
override fun shouldFullSync(callback: (Result<Boolean>) -> Unit) {
runSync(callback) { shouldFullSync() }
}
private fun shouldFullSync(): Boolean {
override fun shouldFullSync(): Boolean {
return true
}
@@ -22,11 +18,7 @@ class NativeSyncApiImpl26(context: Context) : NativeSyncApiImplBase(context), Na
// No-op for Android 10 and below
}
override fun getMediaChanges(callback: (Result<SyncDelta>) -> Unit) {
runSync(callback) { getMediaChanges() }
}
private fun getMediaChanges(): SyncDelta {
override fun getMediaChanges(): SyncDelta {
throw IllegalStateException("Method not supported on this Android version.")
}
@@ -7,8 +7,6 @@ import android.os.Bundle
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.annotation.RequiresExtension
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.serialization.json.Json
@RequiresApi(Build.VERSION_CODES.Q)
@@ -37,11 +35,7 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
}
}
override fun shouldFullSync(callback: (Result<Boolean>) -> Unit) {
runSync(callback) { shouldFullSync() }
}
private fun shouldFullSync(): Boolean =
override fun shouldFullSync(): Boolean =
MediaStore.getVersion(ctx) != prefs.getString(SHARED_PREF_MEDIA_STORE_VERSION_KEY, null)
override fun checkpointSync() {
@@ -55,11 +49,7 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
}
}
override fun getMediaChanges(callback: (Result<SyncDelta>) -> Unit) {
runSync(callback) { getMediaChanges() }
}
private suspend fun getMediaChanges(): SyncDelta {
override fun getMediaChanges(): SyncDelta {
val genMap = getSavedGenerationMap()
val currentVolumes = MediaStore.getExternalVolumeNames(ctx)
val changed = mutableListOf<PlatformAsset>()
@@ -68,7 +58,6 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
var hasChanges = genMap.keys != currentVolumes
for (volume in currentVolumes) {
currentCoroutineContext().ensureActive()
val currentGen = MediaStore.getGeneration(ctx, volume)
val storedGen = genMap[volume] ?: 0
if (currentGen <= storedGen) {
@@ -45,14 +45,12 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
private val ctx: Context = context.applicationContext
private var hashTask: Job? = null
private var syncJob: Job? = null
private val mediaTrashDelegate = MediaTrashDelegate(ctx)
companion object {
private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
private val hashSemaphore = Semaphore(MAX_CONCURRENT_HASH_OPERATIONS)
private const val HASHING_CANCELLED_CODE = "HASH_CANCELLED"
private const val SYNC_CANCELLED_CODE = "SYNC_CANCELLED"
// MediaStore.Files.FileColumns.SPECIAL_FORMAT — S Extensions 21+
// https://developer.android.com/reference/android/provider/MediaStore.Files.FileColumns#SPECIAL_FORMAT
@@ -297,11 +295,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
return PlatformAssetPlaybackStyle.IMAGE
}
fun getAlbums(callback: (Result<List<PlatformAlbum>>) -> Unit) {
runSync(callback) { getAlbums() }
}
private suspend fun getAlbums(): List<PlatformAlbum> {
fun getAlbums(): List<PlatformAlbum> {
val albums = mutableListOf<PlatformAlbum>()
val albumsCount = mutableMapOf<String, Int>()
@@ -328,7 +322,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_MODIFIED)
while (cursor.moveToNext()) {
currentCoroutineContext().ensureActive()
val id = cursor.getString(bucketIdColumn)
val count = albumsCount.getOrDefault(id, 0)
@@ -349,11 +342,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
.sortedBy { it.id }
}
fun getAssetIdsForAlbum(albumId: String, callback: (Result<List<String>>) -> Unit) {
runSync(callback) { getAssetIdsForAlbum(albumId) }
}
private fun getAssetIdsForAlbum(albumId: String): List<String> {
fun getAssetIdsForAlbum(albumId: String): List<String> {
val projection = arrayOf(MediaStore.MediaColumns._ID)
return getCursor(
@@ -377,11 +366,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
)?.use { cursor -> cursor.count.toLong() } ?: 0L
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?, callback: (Result<List<PlatformAsset>>) -> Unit) {
runSync(callback) { getAssetsForAlbum(albumId, updatedTimeCond) }
}
private fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset> {
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset> {
var selection = "$BUCKET_SELECTION AND $MEDIA_SELECTION"
val selectionArgs = mutableListOf(albumId, *MEDIA_SELECTION_ARGS)
@@ -466,24 +451,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
hashTask = null
}
fun cancelSync() {
syncJob?.cancel()
syncJob = null
}
protected fun <T> runSync(callback: (Result<T>) -> Unit, work: suspend () -> T) {
syncJob?.cancel()
syncJob = CoroutineScope(Dispatchers.IO).launch {
try {
completeWhenActive(callback, Result.success(work()))
} catch (e: CancellationException) {
completeWhenActive(callback, Result.failure(FlutterError(SYNC_CANCELLED_CODE, "Sync cancelled", null)))
} catch (e: Exception) {
completeWhenActive(callback, Result.failure(e))
}
}
}
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit) {
mediaTrashDelegate.restoreFromTrashById(mediaId, type) { completeWhenActive(callback, it) }
}
@@ -1,292 +0,0 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
package app.alextran.immich.viewintent
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 ViewIntentPigeonUtils {
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 doubleEquals(a: Double, b: Double): Boolean {
// Normalize -0.0 to 0.0 and handle NaN equality.
return (if (a == 0.0) 0.0 else a) == (if (b == 0.0) 0.0 else b) || (a.isNaN() && b.isNaN())
}
fun floatEquals(a: Float, b: Float): Boolean {
// Normalize -0.0 to 0.0 and handle NaN equality.
return (if (a == 0.0f) 0.0f else a) == (if (b == 0.0f) 0.0f else b) || (a.isNaN() && b.isNaN())
}
fun doubleHash(d: Double): Int {
// Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes.
val normalized = if (d == 0.0) 0.0 else d
val bits = java.lang.Double.doubleToLongBits(normalized)
return (bits xor (bits ushr 32)).toInt()
}
fun floatHash(f: Float): Int {
// Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes.
val normalized = if (f == 0.0f) 0.0f else f
return java.lang.Float.floatToIntBits(normalized)
}
fun deepEquals(a: Any?, b: Any?): Boolean {
if (a === b) {
return true
}
if (a == null || b == null) {
return false
}
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) {
if (a.size != b.size) return false
for (i in a.indices) {
if (!doubleEquals(a[i], b[i])) return false
}
return true
}
if (a is FloatArray && b is FloatArray) {
if (a.size != b.size) return false
for (i in a.indices) {
if (!floatEquals(a[i], b[i])) return false
}
return true
}
if (a is Array<*> && b is Array<*>) {
if (a.size != b.size) return false
for (i in a.indices) {
if (!deepEquals(a[i], b[i])) return false
}
return true
}
if (a is List<*> && b is List<*>) {
if (a.size != b.size) return false
val iterA = a.iterator()
val iterB = b.iterator()
while (iterA.hasNext() && iterB.hasNext()) {
if (!deepEquals(iterA.next(), iterB.next())) return false
}
return true
}
if (a is Map<*, *> && b is Map<*, *>) {
if (a.size != b.size) return false
for (entry in a) {
val key = entry.key
var found = false
for (bEntry in b) {
if (deepEquals(key, bEntry.key)) {
if (deepEquals(entry.value, bEntry.value)) {
found = true
break
} else {
return false
}
}
}
if (!found) return false
}
return true
}
if (a is Double && b is Double) {
return doubleEquals(a, b)
}
if (a is Float && b is Float) {
return floatEquals(a, b)
}
return a == b
}
fun deepHash(value: Any?): Int {
return when (value) {
null -> 0
is ByteArray -> value.contentHashCode()
is IntArray -> value.contentHashCode()
is LongArray -> value.contentHashCode()
is DoubleArray -> {
var result = 1
for (item in value) {
result = 31 * result + doubleHash(item)
}
result
}
is FloatArray -> {
var result = 1
for (item in value) {
result = 31 * result + floatHash(item)
}
result
}
is Array<*> -> {
var result = 1
for (item in value) {
result = 31 * result + deepHash(item)
}
result
}
is List<*> -> {
var result = 1
for (item in value) {
result = 31 * result + deepHash(item)
}
result
}
is Map<*, *> -> {
var result = 0
for (entry in value) {
result += ((deepHash(entry.key) * 31) xor deepHash(entry.value))
}
result
}
is Double -> doubleHash(value)
is Float -> floatHash(value)
else -> value.hashCode()
}
}
}
/**
* 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
) : RuntimeException()
/** Generated class from Pigeon that represents data sent in messages. */
data class ViewIntentPayload (
val path: String? = null,
val mimeType: String,
val localAssetId: String? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): ViewIntentPayload {
val path = pigeonVar_list[0] as String?
val mimeType = pigeonVar_list[1] as String
val localAssetId = pigeonVar_list[2] as String?
return ViewIntentPayload(path, mimeType, localAssetId)
}
}
fun toList(): List<Any?> {
return listOf(
path,
mimeType,
localAssetId,
)
}
override fun equals(other: Any?): Boolean {
if (other == null || other.javaClass != javaClass) {
return false
}
if (this === other) {
return true
}
val other = other as ViewIntentPayload
return ViewIntentPigeonUtils.deepEquals(this.path, other.path) && ViewIntentPigeonUtils.deepEquals(this.mimeType, other.mimeType) && ViewIntentPigeonUtils.deepEquals(this.localAssetId, other.localAssetId)
}
override fun hashCode(): Int {
var result = javaClass.hashCode()
result = 31 * result + ViewIntentPigeonUtils.deepHash(this.path)
result = 31 * result + ViewIntentPigeonUtils.deepHash(this.mimeType)
result = 31 * result + ViewIntentPigeonUtils.deepHash(this.localAssetId)
return result
}
}
private open class ViewIntentPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
129.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
ViewIntentPayload.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) {
is ViewIntentPayload -> {
stream.write(129)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface ViewIntentHostApi {
fun consumeViewIntent(callback: (Result<ViewIntentPayload?>) -> Unit)
companion object {
/** The codec used by ViewIntentHostApi. */
val codec: MessageCodec<Any?> by lazy {
ViewIntentPigeonCodec()
}
/** Sets up an instance of `ViewIntentHostApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: ViewIntentHostApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ViewIntentHostApi.consumeViewIntent$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.consumeViewIntent{ result: Result<ViewIntentPayload?> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(ViewIntentPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(ViewIntentPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
@@ -1,201 +0,0 @@
package app.alextran.immich.viewintent
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.DocumentsContract
import android.provider.MediaStore
import android.provider.OpenableColumns
import android.util.Log
import android.webkit.MimeTypeMap
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.PluginRegistry
import java.io.File
import java.io.FileOutputStream
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
private const val TAG = "ViewIntentPlugin"
class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentListener, ViewIntentHostApi {
private var context: Context? = null
private var activity: Activity? = null
private var unconsumedIntent: Intent? = null
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
context = binding.applicationContext
ViewIntentHostApi.setUp(binding.binaryMessenger, this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
ViewIntentHostApi.setUp(binding.binaryMessenger, null)
ioScope.cancel()
context = null
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
unconsumedIntent = binding.activity.intent
binding.addOnNewIntentListener(this)
}
override fun onDetachedFromActivityForConfigChanges() {
activity = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
onAttachedToActivity(binding)
}
override fun onDetachedFromActivity() {
activity = null
}
override fun onNewIntent(intent: Intent): Boolean {
unconsumedIntent = intent
return false
}
override fun consumeViewIntent(callback: (Result<ViewIntentPayload?>) -> Unit) {
val context = context ?: run {
callback(Result.success(null))
return
}
val intent = unconsumedIntent ?: activity?.intent
if (intent?.action != Intent.ACTION_VIEW) {
callback(Result.success(null))
return
}
val uri = intent.data
if (uri == null) {
callback(Result.success(null))
return
}
ioScope.launch {
try {
val mimeType = context.contentResolver.getType(uri) ?: intent.type
if (mimeType == null || (!mimeType.startsWith("image/") && !mimeType.startsWith("video/"))) {
callback(Result.success(null))
return@launch
}
val localAssetId = extractLocalAssetId(context, uri, mimeType)
val tempFilePath = if (localAssetId == null) {
copyUriToTempFile(context, uri, mimeType)?.absolutePath ?: run {
callback(Result.success(null))
return@launch
}
} else {
null
}
val payload = ViewIntentPayload(
path = tempFilePath,
mimeType = mimeType,
localAssetId = localAssetId,
)
consumeViewIntent(intent)
callback(Result.success(payload))
} catch (e: Exception) {
callback(Result.failure(e))
}
}
}
private fun consumeViewIntent(currentIntent: Intent) {
unconsumedIntent = Intent(currentIntent).apply {
action = null
data = null
type = null
}
activity?.intent = unconsumedIntent
}
private fun extractLocalAssetId(context: Context, uri: Uri, mimeType: String): String? {
return tryExtractDocumentLocalAssetId(context, uri)
?: tryParseContentUriId(uri)
?: resolveLocalIdByNameAndSize(context, uri, mimeType)
}
private fun tryExtractDocumentLocalAssetId(context: Context, uri: Uri): String? {
return try {
if (!DocumentsContract.isDocumentUri(context, uri)) return null
val docId = DocumentsContract.getDocumentId(uri)
if (docId.isBlank() || docId.startsWith("raw:")) return null
docId.substringAfter(':', docId).toLongOrNull()?.toString()
} catch (e: Exception) {
Log.w(TAG, "Failed to resolve local asset id from document URI: $uri", e)
null
}
}
private fun tryParseContentUriId(uri: Uri): String? {
val id = uri.lastPathSegment?.toLongOrNull() ?: return null
return if (id >= 0) id.toString() else null
}
private fun copyUriToTempFile(context: Context, uri: Uri, mimeType: String): File? {
return try {
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.let { ".$it" }
val tempFile = File.createTempFile("view_intent_", extension, context.cacheDir)
context.contentResolver.openInputStream(uri)?.use { inputStream ->
FileOutputStream(tempFile).use { outputStream ->
inputStream.copyTo(outputStream)
}
} ?: return null
tempFile
} catch (_: Exception) {
null
}
}
private fun resolveLocalIdByNameAndSize(context: Context, uri: Uri, mimeType: String): String? {
val metaProjection = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
val (displayName, size) =
try {
context.contentResolver.query(uri, metaProjection, null, null, null)?.use { cursor ->
if (!cursor.moveToFirst()) return null
val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE)
val name = if (nameIdx >= 0) cursor.getString(nameIdx) else null
val bytes = if (sizeIdx >= 0) cursor.getLong(sizeIdx) else -1L
if (name.isNullOrBlank() || bytes < 0) return null
name to bytes
} ?: return null
} catch (_: Exception) {
return null
}
val tableUri = when {
mimeType.startsWith("image/") -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
mimeType.startsWith("video/") -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
else -> return null
}
return try {
context.contentResolver
.query(
tableUri,
arrayOf(MediaStore.MediaColumns._ID),
"${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.SIZE}=?",
arrayOf(displayName, size.toString()),
"${MediaStore.MediaColumns.DATE_MODIFIED} DESC",
)?.use { cursor ->
if (!cursor.moveToFirst()) return null
val idIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID)
if (idIndex < 0) return null
cursor.getLong(idIndex).toString()
}
} catch (_: Exception) {
null
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,154 +0,0 @@
import 'dart:async';
import 'package:drift/drift.dart' show Value;
import 'package:flutter_test/flutter_test.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/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/main.dart' as app;
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/wm_executor.dart';
import 'package:integration_test/integration_test.dart';
import 'package:openapi/api.dart';
import 'test_utils/fake_immich_server.dart';
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
// These tests do real I/O without pumping a widget tree, so disable the fake async clock
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
late Drift drift;
late FakeImmichServer server;
setUpAll(() async {
await app.initApp();
(drift, _) = await Bootstrap.initDomain();
});
setUp(() async {
await workerManagerPatch.init(dynamicSpawning: true);
server = await FakeImmichServer.start();
await ApiService().resolveAndSetEndpoint(server.endpoint);
await drift.delete(drift.userEntity).go();
await Store.delete(StoreKey.syncMigrationStatus);
});
tearDown(() async {
await workerManagerPatch.dispose();
await server.close();
await Store.delete(StoreKey.serverEndpoint);
await Store.delete(StoreKey.syncMigrationStatus);
});
void sendUser(SyncStream stream, String id, String name) {
stream.send(
type: SyncEntityType.userV1.value,
data: SyncUserV1(
id: id,
name: name,
email: '$id@test.com',
hasProfileImage: false,
deletedAt: null,
profileChangedAt: DateTime.utc(2025),
).toJson(),
ack: id,
);
}
Future<bool> dbReadable() async {
try {
await drift.customSelect('SELECT 1').get().timeout(const Duration(seconds: 5));
return true;
} catch (_) {
return false;
}
}
Future<int> userCount() async => (await drift.select(drift.userEntity).get()).length;
// Starts a remote sync and resolves once its /sync/stream request is open.
Future<(Future<bool>, SyncStream)> startSync() async {
final sync = BackgroundSyncManager().syncRemote();
final stream = await server.streamOpened.timeout(
const Duration(seconds: 30),
onTimeout: () => fail('sync isolate never opened /sync/stream'),
);
return (sync, stream);
}
testWidgets('a full sync ingests streamed events into the shared DB', (tester) async {
expect(await userCount(), 0);
final (sync, stream) = await startSync();
sendUser(stream, 'u1', 'Alice');
sendUser(stream, 'u2', 'Bob');
await stream.close();
final result = await sync.timeout(
const Duration(seconds: 30),
onTimeout: () => fail('sync did not complete after the stream ended'),
);
expect(result, isTrue);
expect(await userCount(), 2);
expect(server.ackRequests, greaterThan(0));
});
testWidgets('disposing the pool during an in-flight sync drains promptly', (tester) async {
final (sync, _) = await startSync();
final sw = Stopwatch()..start();
await workerManagerPatch.dispose().timeout(
const Duration(seconds: 15),
onTimeout: () => fail('dispose() hung — worker did not drain and exit'),
);
expect(sw.elapsed, lessThan(const Duration(seconds: 10)), reason: 'abort-driven, not socket-timeout bound');
expect(await sync.timeout(const Duration(seconds: 5), onTimeout: () => false), isFalse);
});
testWidgets('tearing down a worker blocked mid-write leaves the DB usable', (tester) async {
final (sync, stream) = await startSync();
// Hold an exclusive write transaction so the worker's write is blocked. The lock is taken only
// after the stream opens to avoid blocking the worker's own startup DB reads.
final releaseTxn = Completer<void>();
final txnHeld = Completer<void>();
final txn = drift.transaction(() async {
await drift.into(drift.userEntity).insert(
UserEntityCompanion.insert(
id: 'holder',
name: 'holder',
email: 'holder@test.com',
hasProfileImage: const Value(false),
profileChangedAt: Value(DateTime.utc(2025)),
),
);
txnHeld.complete();
await releaseTxn.future;
});
await txnHeld.future;
sendUser(stream, 'u1', 'Alice');
await stream.close();
// dispose() can only finish once the worker unwinds, which is blocked on the
// lock — so start it, release the lock, then await completion.
final disposed = workerManagerPatch.dispose();
releaseTxn.complete();
await txn;
await disposed.timeout(
const Duration(seconds: 15),
onTimeout: () => fail('dispose() hung after releasing the write lock'),
);
await sync.timeout(const Duration(seconds: 5), onTimeout: () => false);
expect(await dbReadable(), isTrue);
final users = await drift.select(drift.userEntity).get();
expect(users.map((u) => u.id), contains('holder'));
});
}
@@ -1,115 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
/// A dummy localhost server that implements only the endpoints that remote-sync touches.
class FakeImmichServer {
FakeImmichServer._(this._server, this.version);
final HttpServer _server;
final (int, int, int) version;
final Completer<SyncStream> _streamOpened = Completer<SyncStream>();
int ackRequests = 0;
String get endpoint => 'http://${_server.address.host}:${_server.port}/api';
/// Resolves when the sync isolate opens `POST /sync/stream`.
Future<SyncStream> get streamOpened => _streamOpened.future;
static Future<FakeImmichServer> start({(int, int, int) version = (3, 0, 0)}) async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final fake = FakeImmichServer._(server, version);
fake._listen();
return fake;
}
void _listen() {
// A connection torn down mid-write during teardown is expected
_server.listen((request) => unawaited(_route(request).catchError((_) {})));
}
Future<void> _route(HttpRequest request) async {
final method = request.method;
final path = request.uri.path;
if (method == 'GET' && path == '/api/server/ping') {
return _respondJson(request, {'res': 'pong'});
}
if (method == 'GET' && path == '/api/server/version') {
final (major, minor, patch) = version;
return _respondJson(request, {'major': major, 'minor': minor, 'patch': patch});
}
if (path == '/api/sync/ack') {
if (method != 'DELETE') {
ackRequests++;
}
return _respondEmpty(request);
}
if (method == 'POST' && path == '/api/sync/stream') {
return _openSyncStream(request);
}
return _respondEmpty(request, status: HttpStatus.notFound);
}
Future<void> _openSyncStream(HttpRequest request) async {
await request.drain<void>();
request.response
..statusCode = HttpStatus.ok
..headers.contentType = ContentType('application', 'jsonlines+json')
..contentLength = -1 // chunked: stays open to stream incrementally
..bufferOutput = false;
// Flush headers so the client's send() resolves and enters its read loop.
await request.response.flush();
if (!_streamOpened.isCompleted) {
_streamOpened.complete(SyncStream._(request.response));
}
}
Future<void> _respondJson(HttpRequest request, Object body) async {
await request.drain<void>();
request.response
..statusCode = HttpStatus.ok
..headers.contentType = ContentType.json
..write(jsonEncode(body));
await request.response.close();
}
Future<void> _respondEmpty(HttpRequest request, {int status = HttpStatus.ok}) async {
await request.drain<void>();
request.response.statusCode = status;
await request.response.close();
}
Future<void> close() async {
if (_streamOpened.isCompleted) {
await (await _streamOpened.future).close();
}
await _server.close(force: true);
}
}
/// Handle to the open `/sync/stream` response: push jsonlines events, then end.
class SyncStream {
SyncStream._(this._response);
final HttpResponse _response;
bool _closed = false;
/// [data] should be a Sync*V1 DTO's `toJson()` so the parser's `fromJson` round-trips it.
void send({required String type, required Object data, required String ack}) {
if (_closed) {
return;
}
_response.write('${jsonEncode({'type': type, 'data': data, 'ack': ack})}\n');
}
Future<void> close() async {
if (_closed) {
return;
}
_closed = true;
await _response.close();
}
}
@@ -121,8 +121,8 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
/**
* Cancels the currently running background task, either due to timeout or external request.
* Only tears down the engine after Dart confirms it's drained. If Dart overruns iOS's grace window,
* the expiration handler still calls setTaskCompleted and iOS suspends us.
* Sends a cancel signal to the Flutter side and sets up a fallback timer to ensure
* the completion handler is eventually called even if Flutter doesn't respond.
*/
func close() {
if isComplete {
@@ -132,6 +132,12 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
flutterApi?.cancel { result in
self.complete(success: false)
}
// Fallback safety mechanism: ensure completion is called within 2 seconds
// This prevents the background task from hanging indefinitely if Flutter doesn't respond
Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in
self.complete(success: false)
}
}
+42 -58
View File
@@ -526,17 +526,16 @@ class MessagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol NativeSyncApi {
func shouldFullSync(completion: @escaping (Result<Bool, Error>) -> Void)
func getMediaChanges(completion: @escaping (Result<SyncDelta, Error>) -> Void)
func shouldFullSync() throws -> Bool
func getMediaChanges() throws -> SyncDelta
func checkpointSync() throws
func clearSyncCheckpoint() throws
func getAssetIdsForAlbum(albumId: String, completion: @escaping (Result<[String], Error>) -> Void)
func getAlbums(completion: @escaping (Result<[PlatformAlbum], Error>) -> Void)
func getAssetIdsForAlbum(albumId: String) throws -> [String]
func getAlbums() throws -> [PlatformAlbum]
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?, completion: @escaping (Result<[PlatformAsset], Error>) -> Void)
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
func cancelHashing() throws
func cancelSync() throws
func getTrashedAssets() throws -> [String: [PlatformAsset]]
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void)
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
@@ -556,28 +555,26 @@ class NativeSyncApiSetup {
let shouldFullSyncChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
shouldFullSyncChannel.setMessageHandler { _, reply in
api.shouldFullSync { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
do {
let result = try api.shouldFullSync()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
shouldFullSyncChannel.setMessageHandler(nil)
}
let getMediaChangesChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
let getMediaChangesChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getMediaChangesChannel.setMessageHandler { _, reply in
api.getMediaChanges { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
do {
let result = try api.getMediaChanges()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
@@ -609,33 +606,33 @@ class NativeSyncApiSetup {
} else {
clearSyncCheckpointChannel.setMessageHandler(nil)
}
let getAssetIdsForAlbumChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
let getAssetIdsForAlbumChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getAssetIdsForAlbumChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let albumIdArg = args[0] as! String
api.getAssetIdsForAlbum(albumId: albumIdArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
do {
let result = try api.getAssetIdsForAlbum(albumId: albumIdArg)
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
getAssetIdsForAlbumChannel.setMessageHandler(nil)
}
let getAlbumsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
let getAlbumsChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getAlbumsChannel.setMessageHandler { _, reply in
api.getAlbums { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
do {
let result = try api.getAlbums()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
@@ -659,19 +656,19 @@ class NativeSyncApiSetup {
} else {
getAssetsCountSinceChannel.setMessageHandler(nil)
}
let getAssetsForAlbumChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
let getAssetsForAlbumChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getAssetsForAlbumChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let albumIdArg = args[0] as! String
let updatedTimeCondArg: Int64? = nilOrValue(args[1])
api.getAssetsForAlbum(albumId: albumIdArg, updatedTimeCond: updatedTimeCondArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
do {
let result = try api.getAssetsForAlbum(albumId: albumIdArg, updatedTimeCond: updatedTimeCondArg)
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
@@ -710,19 +707,6 @@ class NativeSyncApiSetup {
} else {
cancelHashingChannel.setMessageHandler(nil)
}
let cancelSyncChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelSync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
cancelSyncChannel.setMessageHandler { _, reply in
do {
try api.cancelSync()
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
cancelSyncChannel.setMessageHandler(nil)
}
let getTrashedAssetsChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
+45 -102
View File
@@ -39,9 +39,6 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
private static let hashCancelledCode = "HASH_CANCELLED"
private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil))
private var syncTask: Task<Void?, Error>?
private static let syncCancelledCode = "SYNC_CANCELLED"
private static let syncCancelled = PigeonError(code: syncCancelledCode, message: "Sync cancelled", details: nil)
init(with defaults: UserDefaults = .standard) {
self.defaults = defaults
@@ -74,11 +71,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
saveChangeToken(token: PHPhotoLibrary.shared().currentChangeToken)
}
func shouldFullSync(completion: @escaping (Result<Bool, Error>) -> Void) {
runSync(completion) { $0.shouldFullSync() }
}
private func shouldFullSync() -> Bool {
func shouldFullSync() -> Bool {
guard #available(iOS 16, *),
PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized,
let storedToken = getChangeToken() else {
@@ -94,17 +87,12 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return false
}
func getAlbums(completion: @escaping (Result<[PlatformAlbum], Error>) -> Void) {
runSync(completion) { try $0.getAlbums() }
}
private func getAlbums() throws -> [PlatformAlbum] {
func getAlbums() throws -> [PlatformAlbum] {
var albums: [PlatformAlbum] = []
for type in albumTypes {
albumTypes.forEach { type in
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
for i in 0..<collections.count {
try Task.checkCancellation()
let album = collections.object(at: i)
// Ignore recovered album
@@ -138,11 +126,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return albums.sorted { $0.id < $1.id }
}
func getMediaChanges(completion: @escaping (Result<SyncDelta, Error>) -> Void) {
runSync(completion) { try $0.getMediaChanges() }
}
private func getMediaChanges() throws -> SyncDelta {
func getMediaChanges() throws -> SyncDelta {
guard #available(iOS 16, *) else {
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil)
}
@@ -162,49 +146,51 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return SyncDelta(hasChanges: false, updates: [], deletes: [], assetAlbums: [:])
}
let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken)
var updatedAssets: Set<AssetWrapper> = []
var deletedAssets: Set<String> = []
for change in changes {
try Task.checkCancellation()
guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue }
do {
let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken)
let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers)
deletedAssets.formUnion(details.deletedLocalIdentifiers)
var updatedAssets: Set<AssetWrapper> = []
var deletedAssets: Set<String> = []
if (updated.isEmpty) { continue }
let options = PHFetchOptions()
options.includeHiddenAssets = false
let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options)
for i in 0..<result.count {
let asset = result.object(at: i)
for change in changes {
guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue }
// Asset wrapper only uses the id for comparison. Multiple change can contain the same asset, skip duplicate changes
let predicate = PlatformAsset(
id: asset.localIdentifier,
name: "",
type: 0,
durationMs: 0,
orientation: 0,
isFavorite: false,
playbackStyle: .unknown
)
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
continue
let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers)
deletedAssets.formUnion(details.deletedLocalIdentifiers)
if (updated.isEmpty) { continue }
let options = PHFetchOptions()
options.includeHiddenAssets = false
let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options)
for i in 0..<result.count {
let asset = result.object(at: i)
// Asset wrapper only uses the id for comparison. Multiple change can contain the same asset, skip duplicate changes
let predicate = PlatformAsset(
id: asset.localIdentifier,
name: "",
type: 0,
durationMs: 0,
orientation: 0,
isFavorite: false,
playbackStyle: .unknown
)
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
continue
}
let domainAsset = AssetWrapper(with: asset.toPlatformAsset())
updatedAssets.insert(domainAsset)
}
let domainAsset = AssetWrapper(with: asset.toPlatformAsset())
updatedAssets.insert(domainAsset)
}
let updates = Array(updatedAssets.map { $0.asset })
return SyncDelta(hasChanges: true, updates: updates, deletes: Array(deletedAssets), assetAlbums: buildAssetAlbumsMap(assets: updates))
}
let updates = Array(updatedAssets.map { $0.asset })
return SyncDelta(hasChanges: true, updates: updates, deletes: Array(deletedAssets), assetAlbums: buildAssetAlbumsMap(assets: updates))
}
private func buildAssetAlbumsMap(assets: Array<PlatformAsset>) -> [String: [String]] {
guard !assets.isEmpty else {
return [:]
@@ -227,11 +213,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return albumAssets
}
func getAssetIdsForAlbum(albumId: String, completion: @escaping (Result<[String], Error>) -> Void) {
runSync(completion) { try $0.getAssetIdsForAlbum(albumId: albumId) }
}
private func getAssetIdsForAlbum(albumId: String) throws -> [String] {
func getAssetIdsForAlbum(albumId: String) throws -> [String] {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else {
return []
@@ -241,14 +223,9 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
let options = PHFetchOptions()
options.includeHiddenAssets = false
let assets = getAssetsFromAlbum(in: album, options: options)
assets.enumerateObjects { (asset, _, stop) in
if Task.isCancelled {
stop.pointee = true
return
}
assets.enumerateObjects { (asset, _, _) in
ids.append(asset.localIdentifier)
}
try Task.checkCancellation()
return ids
}
@@ -266,11 +243,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return Int64(assets.count)
}
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?, completion: @escaping (Result<[PlatformAsset], Error>) -> Void) {
runSync(completion) { try $0.getAssetsForAlbum(albumId: albumId, updatedTimeCond: updatedTimeCond) }
}
private func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] {
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else {
return []
@@ -289,14 +262,9 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
}
var assets: [PlatformAsset] = []
result.enumerateObjects { (asset, _, stop) in
if Task.isCancelled {
stop.pointee = true
return
}
result.enumerateObjects { (asset, _, _) in
assets.append(asset.toPlatformAsset())
}
try Task.checkCancellation()
return assets
}
@@ -356,31 +324,6 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
hashTask = nil
}
func cancelSync() {
syncTask?.cancel()
syncTask = nil
}
private func runSync<T>(
_ completion: @escaping (Result<T, Error>) -> Void,
_ work: @escaping (NativeSyncApiImpl) throws -> T
) {
syncTask?.cancel()
syncTask = Task { [weak self] in
guard let self else { return nil }
let result: Result<T, Error>
do {
result = .success(try work(self))
} catch is CancellationError {
result = .failure(Self.syncCancelled)
} catch {
result = .failure(error)
}
self.completeWhenActive(for: completion, with: result)
return nil
}
}
private func hashAsset(_ asset: PHAsset, allowNetworkAccess: Bool) async -> HashResult? {
class RequestRef {
var id: PHAssetResourceDataRequestID?
@@ -113,35 +113,9 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
@override
Future<void> onIosUpload(bool isRefresh, int? maxSeconds) async {
_logger.info('iOS background upload started with maxSeconds: ${maxSeconds}s');
final sw = Stopwatch()..start();
try {
final budget = maxSeconds != null ? Duration(seconds: maxSeconds - 1) : null;
final sync = _ref?.read(backgroundSyncProvider);
if (sync == null) {
return;
}
// Run sync local, sync remote, hash and backup concurrently so the bg
// refresh task (20s budget) can make progress on all four instead of
// racing them sequentially. Phases are independent at the data layer:
// hash and handle_backup read drift state and tolerate stale reads
// (server-side dedup catches the rare race). The single budget caps the
// whole batch; no phase needs its own timeout.
final all = Future.wait<dynamic>([sync.syncLocal(), sync.syncRemote(), sync.hashAssets(), _handleBackup()]);
if (budget != null) {
await all.timeout(budget, onTimeout: () => <dynamic>[]);
} else {
await all;
}
} catch (error, stack) {
_logger.severe("Failed to complete iOS background upload", error, stack);
} finally {
sw.stop();
_logger.info("iOS background upload completed in ${sw.elapsed.inSeconds}s");
await _cleanup();
}
final hashTimeout = isRefresh ? const Duration(seconds: 5) : Duration(minutes: _isBackupEnabled ? 3 : 6);
final backupTimeout = maxSeconds != null ? Duration(seconds: maxSeconds - 1) : null;
return _backgroundLoop(hashTimeout: hashTimeout, backupTimeout: backupTimeout, debugLabel: 'iOS background upload');
}
Future<void> _backgroundLoop({
@@ -214,14 +188,20 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
if (!_cancellationToken.isCompleted) {
_cancellationToken.complete();
}
final cleanupFutures = [
nativeSyncApi?.cancelHashing(),
workerManagerPatch.dispose().catchError((_) async {
// Discard any errors on the dispose call
return;
}),
LogService.I.dispose(),
Store.dispose(),
// Workers share one sqlite connection, so DB teardown must wait until every worker has stopped using it.
await Future.wait([
if (backgroundSyncManager != null) backgroundSyncManager.cancel(),
if (nativeSyncApi != null) nativeSyncApi.cancelHashing(),
]);
await workerManagerPatch.dispose().catchError((_) async {});
await Future.wait([LogService.I.dispose(), Store.dispose(), _drift.optimize(allTables: true)]);
backgroundSyncManager?.cancel(),
_drift.optimize(allTables: true),
];
await Future.wait(cleanupFutures.nonNulls);
await _drift.close();
await _driftLogger.close();
+4 -10
View File
@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
@@ -19,7 +17,7 @@ class HashService {
final DriftLocalAssetRepository _localAssetRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final NativeSyncApi _nativeSyncApi;
final Completer<void>? _cancellation;
final bool Function()? _cancelChecker;
final _log = Logger('HashService');
HashService({
@@ -27,15 +25,11 @@ class HashService {
required this._localAssetRepository,
required this._trashedLocalAssetRepository,
required this._nativeSyncApi,
this._cancellation,
this._cancelChecker,
int? batchSize,
}) : _batchSize = batchSize ?? kBatchHashFileLimit {
// Stop the in-flight native hash call promptly on cancellation; the loops
// below also observe [isCancelled] to bail between batches.
_cancellation?.future.then((_) => _nativeSyncApi.cancelHashing().onError(_log.warning));
}
}) : _batchSize = batchSize ?? kBatchHashFileLimit;
bool get isCancelled => _cancellation?.isCompleted ?? false;
bool get isCancelled => _cancelChecker?.call() ?? false;
Future<void> hashAssets() async {
_log.info("Starting hashing of assets");
@@ -2,7 +2,6 @@ import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
@@ -18,8 +17,6 @@ import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:logging/logging.dart';
const String _kSyncCancelledCode = "SYNC_CANCELLED";
class LocalSyncService {
final DriftLocalAlbumRepository _localAlbumRepository;
// ignore: unused_field
@@ -28,7 +25,6 @@ class LocalSyncService {
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository;
final IPermissionRepository _permissionRepository;
final Completer<void>? _cancellation;
final Logger _log = Logger("DeviceSyncService");
LocalSyncService({
@@ -38,12 +34,7 @@ class LocalSyncService {
required this._trashedLocalAssetRepository,
required this._assetMediaRepository,
required this._permissionRepository,
this._cancellation,
}) {
_cancellation?.future.then((_) => _nativeSyncApi.cancelSync().onError(_log.warning));
}
bool get _isCancelled => _cancellation?.isCompleted ?? false;
});
Future<void> sync({bool full = false}) async {
final Stopwatch stopwatch = Stopwatch()..start();
@@ -90,10 +81,6 @@ class LocalSyncService {
// detect album deletions from the native side
if (CurrentPlatform.isAndroid) {
for (final album in dbAlbums) {
if (_isCancelled) {
_log.warning("Local sync cancelled. Stopped processing albums.");
return;
}
final deviceIds = await _nativeSyncApi.getAssetIdsForAlbum(album.id);
await _localAlbumRepository.syncDeletes(album.id, deviceIds);
}
@@ -104,10 +91,6 @@ class LocalSyncService {
// does not include changes for cloud albums.
final cloudAlbums = deviceAlbums.where((a) => a.isCloud).toLocalAlbums();
for (final album in cloudAlbums) {
if (_isCancelled) {
_log.warning("Local sync cancelled. Stopped processing cloud albums.");
return;
}
final dbAlbum = dbAlbums.firstWhereOrNull((a) => a.id == album.id);
if (dbAlbum == null) {
_log.warning("Cloud album ${album.name} not found in local database. Skipping sync.");
@@ -119,12 +102,6 @@ class LocalSyncService {
await _mapIosCloudIds(newAssets);
}
await _nativeSyncApi.checkpointSync();
} on PlatformException catch (e, s) {
if (e.code == _kSyncCancelledCode) {
_log.warning("Local sync cancelled");
} else {
_log.severe("Error performing device sync", e, s);
}
} catch (e, s) {
_log.severe("Error performing device sync", e, s);
} finally {
@@ -152,21 +129,12 @@ class LocalSyncService {
await _nativeSyncApi.checkpointSync();
stopwatch.stop();
_log.info("Full device sync took - ${stopwatch.elapsedMilliseconds}ms");
} on PlatformException catch (e, s) {
if (e.code == _kSyncCancelledCode) {
_log.warning("Full device sync cancelled");
} else {
_log.severe("Error performing full device sync", e, s);
}
} catch (e, s) {
_log.severe("Error performing full device sync", e, s);
}
}
Future<void> addAlbum(LocalAlbum album) async {
if (_isCancelled) {
return;
}
try {
_log.fine("Adding device album ${album.name}");
@@ -194,9 +162,6 @@ class LocalSyncService {
// The deviceAlbum is ignored since we are going to refresh it anyways
FutureOr<bool> updateAlbum(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async {
if (_isCancelled) {
return false;
}
try {
_log.fine("Syncing device album ${dbAlbum.name}");
+3 -9
View File
@@ -112,16 +112,10 @@ class LogService {
return _flushBuffer();
}
Future<void> dispose() async {
Future<void> dispose() {
_flushTimer?.cancel();
_flushTimer = null;
await _logSubscription.cancel();
await _flushBuffer();
// Allow a subsequent init() (e.g. when a worker isolate is reused) to
// create a fresh instance instead of returning this disposed one.
if (identical(_instance, this)) {
_instance = null;
}
_logSubscription.cancel();
return _flushBuffer();
}
Future<void> _flushBuffer() async {
@@ -8,7 +8,6 @@ import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:openapi/api.dart' show Optional;
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:logging/logging.dart';
@@ -138,7 +137,7 @@ class RemoteAlbumService {
Future<RemoteAlbum> updateAlbum(
String albumId, {
String? name,
Optional<String?> description = const Optional.absent(),
String? description,
String? thumbnailAssetId,
bool? isActivityEnabled,
AlbumAssetOrder? order,
@@ -54,13 +54,7 @@ class StoreService {
/// Disposes the store and cancels the subscription. To reuse the store call init() again
Future<void> dispose() async {
await _storeUpdateSubscription?.cancel();
_storeUpdateSubscription = null;
_cache.clear();
// Allow a subsequent init() (e.g. when a worker isolate is reused) to
// create a fresh instance instead of returning this disposed one.
if (identical(_instance, this)) {
_instance = null;
}
}
/// Returns the cached value for [key], or `null`
@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
@@ -7,7 +5,6 @@ import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/utils/debug_print.dart';
@@ -19,7 +16,6 @@ final syncLinkedAlbumServiceProvider = Provider(
ref.watch(remoteAlbumRepository),
ref.watch(driftAlbumApiRepositoryProvider),
ref.watch(storeServiceProvider),
cancellation: ref.watch(cancellationProvider),
),
);
@@ -28,15 +24,13 @@ class SyncLinkedAlbumService {
final DriftRemoteAlbumRepository _remoteAlbumRepository;
final DriftAlbumApiRepository _albumApiRepository;
final StoreService _storeService;
final Completer<void>? _cancellation;
SyncLinkedAlbumService(
this._localAlbumRepository,
this._remoteAlbumRepository,
this._albumApiRepository,
this._storeService, {
this._cancellation,
});
this._storeService,
);
final _log = Logger("SyncLinkedAlbumService");
@@ -61,11 +55,7 @@ class SyncLinkedAlbumService {
final assetIds = await _remoteAlbumRepository.getLinkedAssetIds(userId, localAlbum.id, linkedRemoteAlbumId);
_log.fine("Syncing ${assetIds.length} assets to remote album: ${remoteAlbum.name}");
if (assetIds.isNotEmpty) {
final album = await _albumApiRepository.addAssets(
remoteAlbum.id,
assetIds,
abortTrigger: _cancellation?.future,
);
final album = await _albumApiRepository.addAssets(remoteAlbum.id, assetIds);
await _remoteAlbumRepository.addAssets(remoteAlbum.id, album.added);
}
}),
@@ -38,7 +38,7 @@ class SyncStreamService {
final IPermissionRepository _permissionRepository;
final SyncMigrationRepository _syncMigrationRepository;
final ApiService _api;
final Completer<void>? _cancellation;
final bool Function()? _cancelChecker;
SyncStreamService({
required this._syncApiRepository,
@@ -49,10 +49,10 @@ class SyncStreamService {
required this._permissionRepository,
required this._syncMigrationRepository,
required this._api,
this._cancellation,
this._cancelChecker,
});
bool get isCancelled => _cancellation?.isCompleted ?? false;
bool get isCancelled => _cancelChecker?.call() ?? false;
Future<bool> sync() async {
_logger.info("Remote sync request for user");
@@ -80,15 +80,10 @@ class SyncStreamService {
_handleEvents,
serverVersion: serverSemVer,
onReset: () => shouldReset = true,
abortSignal: _cancellation?.future,
);
if (shouldReset) {
_logger.info("Resetting sync state as requested by server");
await _syncApiRepository.streamChanges(
_handleEvents,
serverVersion: serverSemVer,
abortSignal: _cancellation?.future,
);
await _syncApiRepository.streamChanges(_handleEvents, serverVersion: serverSemVer);
}
previousLength = migrations.length;
@@ -323,7 +318,7 @@ class SyncStreamService {
}
Future<void> handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) async {
if (batchData.isEmpty || isCancelled) {
if (batchData.isEmpty) {
return;
}
@@ -366,7 +361,7 @@ class SyncStreamService {
}
Future<void> handleWsAssetUploadReadyV2Batch(List<dynamic> batchData) async {
if (batchData.isEmpty || isCancelled) {
if (batchData.isEmpty) {
return;
}
@@ -409,9 +404,6 @@ class SyncStreamService {
}
Future<void> handleWsAssetEditReadyV1(dynamic data) async {
if (isCancelled) {
return;
}
_logger.info('Processing AssetEditReadyV1 event');
try {
@@ -452,9 +444,6 @@ class SyncStreamService {
}
Future<void> handleWsAssetEditReadyV2(dynamic data) async {
if (isCancelled) {
return;
}
_logger.info('Processing AssetEditReadyV2 event');
try {
+41 -15
View File
@@ -50,28 +50,54 @@ class BackgroundSyncManager {
});
Future<void> cancel() async {
final tasks = [
_syncTask,
_syncWebsocketTask,
_cloudIdSyncTask,
_linkedAlbumSyncTask,
_deviceAlbumSyncTask,
_hashTask,
];
final futures = [
for (final task in tasks)
if (task != null) task.future,
];
for (final task in tasks) {
task?.cancel();
final futures = <Future>[];
if (_syncTask != null) {
futures.add(_syncTask!.future);
}
_syncTask?.cancel();
_syncTask = null;
if (_syncWebsocketTask != null) {
futures.add(_syncWebsocketTask!.future);
}
_syncWebsocketTask?.cancel();
_syncWebsocketTask = null;
if (_cloudIdSyncTask != null) {
futures.add(_cloudIdSyncTask!.future);
}
_cloudIdSyncTask?.cancel();
_cloudIdSyncTask = null;
if (_linkedAlbumSyncTask != null) {
futures.add(_linkedAlbumSyncTask!.future);
}
_linkedAlbumSyncTask?.cancel();
_linkedAlbumSyncTask = null;
_deviceAlbumSyncTask = null;
try {
await Future.wait(futures);
} on CanceledError {
// Ignore cancellation errors
}
}
Future<void> cancelLocal() async {
final futures = <Future>[];
if (_hashTask != null) {
futures.add(_hashTask!.future);
}
_hashTask?.cancel();
_hashTask = null;
if (_deviceAlbumSyncTask != null) {
futures.add(_deviceAlbumSyncTask!.future);
}
_deviceAlbumSyncTask?.cancel();
_deviceAlbumSyncTask = null;
try {
await Future.wait(futures);
} on CanceledError {
+7 -31
View File
@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
@@ -11,7 +9,6 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
@@ -54,10 +51,9 @@ Future<void> syncCloudIds(ProviderContainer ref) async {
}
final assetApi = ref.read(apiServiceProvider).assetsApi;
final cancellation = ref.read(cancellationProvider);
// Process cloud IDs in paginated batches
await _processCloudIdMappingsInBatches(db, currentUser.id, assetApi, canBulkUpdateMetadata, logger, cancellation);
await _processCloudIdMappingsInBatches(db, currentUser.id, assetApi, canBulkUpdateMetadata, logger);
}
Future<void> _processCloudIdMappingsInBatches(
@@ -66,17 +62,12 @@ Future<void> _processCloudIdMappingsInBatches(
AssetsApi assetsApi,
bool canBulkUpdate,
Logger logger,
Completer<void> cancellation,
) async {
const pageSize = 20000;
String? lastLocalId;
final seenRemoteAssetIds = <String>{};
while (true) {
if (cancellation.isCompleted) {
logger.warning('Cloud ID migration cancelled. Stopping batch processing.');
break;
}
final mappings = await _fetchCloudIdMappings(drift, userId, pageSize, lastLocalId);
if (mappings.isEmpty) {
break;
@@ -107,9 +98,9 @@ Future<void> _processCloudIdMappingsInBatches(
if (items.isNotEmpty) {
if (canBulkUpdate) {
await _bulkUpdateCloudIds(assetsApi, items, cancellation.future);
await _bulkUpdateCloudIds(assetsApi, items);
} else {
await _sequentialUpdateCloudIds(assetsApi, items, cancellation);
await _sequentialUpdateCloudIds(assetsApi, items);
}
}
@@ -120,35 +111,20 @@ Future<void> _processCloudIdMappingsInBatches(
}
}
Future<void> _sequentialUpdateCloudIds(
AssetsApi assetsApi,
List<AssetMetadataBulkUpsertItemDto> items,
Completer<void> cancellation,
) async {
Future<void> _sequentialUpdateCloudIds(AssetsApi assetsApi, List<AssetMetadataBulkUpsertItemDto> items) async {
for (final item in items) {
if (cancellation.isCompleted) {
break;
}
final upsertItem = AssetMetadataUpsertItemDto(key: item.key, value: item.value);
try {
await assetsApi.updateAssetMetadata(
item.assetId,
AssetMetadataUpsertDto(items: [upsertItem]),
abortTrigger: cancellation.future,
);
await assetsApi.updateAssetMetadata(item.assetId, AssetMetadataUpsertDto(items: [upsertItem]));
} catch (error, stack) {
Logger('migrateCloudIds').warning('Failed to update metadata for asset ${item.assetId}', error, stack);
}
}
}
Future<void> _bulkUpdateCloudIds(
AssetsApi assetsApi,
List<AssetMetadataBulkUpsertItemDto> items,
Future<void> abortTrigger,
) async {
Future<void> _bulkUpdateCloudIds(AssetsApi assetsApi, List<AssetMetadataBulkUpsertItemDto> items) async {
try {
await assetsApi.updateBulkAssetMetadata(AssetMetadataBulkUpsertDto(items: items), abortTrigger: abortTrigger);
await assetsApi.updateBulkAssetMetadata(AssetMetadataBulkUpsertDto(items: items));
} catch (error, stack) {
Logger('migrateCloudIds').warning('Failed to bulk update metadata', error, stack);
}
+5 -5
View File
@@ -18,11 +18,11 @@ extension DTOToAsset on api.AssetResponseDto {
height: height?.toInt(),
width: width?.toInt(),
isFavorite: isFavorite,
livePhotoVideoId: livePhotoVideoId.orElse(null),
livePhotoVideoId: livePhotoVideoId,
thumbHash: thumbhash,
localId: null,
type: type.toAssetType(),
stackId: stack.orElse(null)?.id,
stackId: stack?.id,
isEdited: isEdited,
);
}
@@ -41,13 +41,13 @@ extension DTOToAsset on api.AssetResponseDto {
height: height?.toInt(),
width: width?.toInt(),
isFavorite: isFavorite,
livePhotoVideoId: livePhotoVideoId.orElse(null),
livePhotoVideoId: livePhotoVideoId,
thumbHash: thumbhash,
localId: null,
type: type.toAssetType(),
stackId: stack.orElse(null)?.id,
stackId: stack?.id,
isEdited: isEdited,
exifInfo: exifInfo.orElse(null) != null ? ExifDtoConverter.fromDto(exifInfo.orElse(null)!) : const ExifInfo(),
exifInfo: exifInfo != null ? ExifDtoConverter.fromDto(exifInfo!) : const ExifInfo(),
);
}
}
@@ -6,7 +6,6 @@ import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)')
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)')
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)')
class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
const LocalAssetEntity();
@@ -1348,7 +1348,3 @@ i0.Index get idxLocalAssetCloudId => i0.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
i0.Index get idxLocalAssetCreatedAt => i0.Index(
'idx_local_asset_created_at',
'CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)',
);
@@ -98,7 +98,7 @@ class Drift extends $Drift {
}
@override
int get schemaVersion => 28;
int get schemaVersion => 27;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -279,9 +279,6 @@ class Drift extends $Drift {
from26To27: (m, v27) async {
await customStatement('ALTER TABLE metadata RENAME TO settings');
},
from27To28: (m, v28) async {
await m.createIndex(v28.idxLocalAssetCreatedAt);
},
),
);
@@ -112,7 +112,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
i7.idxLocalAlbumAssetAlbumAsset,
i4.idxLocalAssetChecksum,
i4.idxLocalAssetCloudId,
i4.idxLocalAssetCreatedAt,
i3.idxStackPrimaryAssetId,
i2.uQRemoteAssetsOwnerChecksum,
i2.uQRemoteAssetsOwnerLibraryChecksum,
@@ -14083,554 +14083,6 @@ final class Schema27 extends i0.VersionedSchema {
);
}
final class Schema28 extends i0.VersionedSchema {
Schema28({required super.database}) : super(version: 28);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAlbumAssetAlbumAsset,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxLocalAssetCreatedAt,
idxStackPrimaryAssetId,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
idxRemoteAssetStackId,
idxRemoteAssetOwnerVisibilityDeletedCreated,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
assetEditEntity,
settings,
idxPartnerSharedWithId,
idxLatLng,
idxRemoteExifCity,
idxRemoteAlbumAssetAlbumAsset,
idxRemoteAssetCloudId,
idxPersonOwnerId,
idxAssetFacePersonId,
idxAssetFaceAssetId,
idxAssetFaceVisiblePerson,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
idxAssetEditAssetId,
];
late final Shape33 userEntity = Shape33(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_110,
_column_111,
_column_112,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape50 remoteAssetEntity = Shape50(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_119,
_column_120,
_column_121,
_column_122,
_column_123,
_column_124,
_column_212,
_column_125,
_column_126,
_column_127,
_column_128,
_column_129,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape35 stackEntity = Shape35(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_130,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape36 localAssetEntity = Shape36(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_131,
_column_120,
_column_132,
_column_133,
_column_134,
_column_135,
_column_136,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape48 remoteAlbumEntity = Shape48(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_138,
_column_114,
_column_115,
_column_139,
_column_140,
_column_141,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape38 localAlbumEntity = Shape38(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_115,
_column_142,
_column_143,
_column_144,
_column_145,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape39 localAlbumAssetEntity = Shape39(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_146, _column_147, _column_145],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
'idx_local_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxLocalAssetCloudId = i1.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
final i1.Index idxLocalAssetCreatedAt = i1.Index(
'idx_local_asset_created_at',
'CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)',
);
final i1.Index idxStackPrimaryAssetId = i1.Index(
'idx_stack_primary_asset_id',
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetStackId = i1.Index(
'idx_remote_asset_stack_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
);
final i1.Index idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index(
'idx_remote_asset_owner_visibility_deleted_created',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
);
late final Shape40 authUserEntity = Shape40(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_148,
_column_110,
_column_111,
_column_149,
_column_150,
_column_151,
_column_152,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_153, _column_154, _column_155],
attachedDatabase: database,
),
alias: null,
);
late final Shape41 partnerEntity = Shape41(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_156, _column_157, _column_158],
attachedDatabase: database,
),
alias: null,
);
late final Shape42 remoteExifEntity = Shape42(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_160,
_column_161,
_column_162,
_column_163,
_column_164,
_column_117,
_column_116,
_column_165,
_column_166,
_column_167,
_column_168,
_column_135,
_column_136,
_column_169,
_column_170,
_column_171,
_column_172,
_column_173,
_column_174,
_column_175,
_column_176,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_159, _column_177],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_177, _column_153, _column_178],
attachedDatabase: database,
),
alias: null,
);
late final Shape43 remoteAssetCloudIdEntity = Shape43(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_179,
_column_180,
_column_134,
_column_135,
_column_136,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape44 memoryEntity = Shape44(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_124,
_column_121,
_column_113,
_column_181,
_column_182,
_column_183,
_column_184,
_column_185,
_column_186,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_159, _column_187],
attachedDatabase: database,
),
alias: null,
);
late final Shape45 personEntity = Shape45(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_108,
_column_188,
_column_189,
_column_190,
_column_191,
_column_192,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape46 assetFaceEntity = Shape46(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_193,
_column_194,
_column_195,
_column_196,
_column_197,
_column_198,
_column_199,
_column_200,
_column_201,
_column_124,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_202, _column_203, _column_204],
attachedDatabase: database,
),
alias: null,
);
late final Shape47 trashedLocalAssetEntity = Shape47(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_205,
_column_131,
_column_120,
_column_132,
_column_206,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape32 assetEditEntity = Shape32(
source: i0.VersionedTable(
entityName: 'asset_edit_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_207,
_column_208,
_column_209,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape49 settings = Shape49(
source: i0.VersionedTable(
entityName: 'settings',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY("key")'],
columns: [_column_210, _column_211, _column_115],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxPartnerSharedWithId = i1.Index(
'idx_partner_shared_with_id',
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxRemoteExifCity = i1.Index(
'idx_remote_exif_city',
'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL',
);
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
'idx_remote_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxRemoteAssetCloudId = i1.Index(
'idx_remote_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
);
final i1.Index idxPersonOwnerId = i1.Index(
'idx_person_owner_id',
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
);
final i1.Index idxAssetFacePersonId = i1.Index(
'idx_asset_face_person_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
);
final i1.Index idxAssetFaceAssetId = i1.Index(
'idx_asset_face_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
);
final i1.Index idxAssetFaceVisiblePerson = i1.Index(
'idx_asset_face_visible_person',
'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL',
);
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
final i1.Index idxAssetEditAssetId = i1.Index(
'idx_asset_edit_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
);
}
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -14658,7 +14110,6 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -14792,11 +14243,6 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from26To27(migrator, schema);
return 27;
case 27:
final schema = Schema28(database: database);
final migrator = i1.Migrator(database, schema);
await from27To28(migrator, schema);
return 28;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -14830,7 +14276,6 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@@ -14859,6 +14304,5 @@ i1.OnUpgrade stepByStep({
from24To25: from24To25,
from25To26: from25To26,
from26To27: from26To27,
from27To28: from27To28,
),
);
@@ -241,7 +241,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
])
..where(_db.localAlbumAssetEntity.albumId.equals(albumId) & _db.localAssetEntity.checksum.isNull())
..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)]);
..orderBy([OrderingTerm.asc(_db.localAssetEntity.id)]);
return query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get();
}
@@ -20,64 +20,50 @@ class SearchApiRepository extends ApiRepository {
(filter.assetId != null && filter.assetId!.isNotEmpty)) {
return _api.searchSmart(
SmartSearchDto(
query: filter.context == null ? const Optional.absent() : Optional.present(filter.context!),
queryAssetId: filter.assetId == null ? const Optional.absent() : Optional.present(filter.assetId!),
language: filter.language == null ? const Optional.absent() : Optional.present(filter.language!),
country: filter.location.country == null
? const Optional.absent()
: Optional.present(filter.location.country!),
state: filter.location.state == null ? const Optional.absent() : Optional.present(filter.location.state!),
city: filter.location.city == null ? const Optional.absent() : Optional.present(filter.location.city!),
make: filter.camera.make == null ? const Optional.absent() : Optional.present(filter.camera.make!),
model: filter.camera.model == null ? const Optional.absent() : Optional.present(filter.camera.model!),
takenAfter: filter.date.takenAfter == null
? const Optional.absent()
: Optional.present(filter.date.takenAfter!),
takenBefore: filter.date.takenBefore == null
? const Optional.absent()
: Optional.present(filter.date.takenBefore!),
visibility: Optional.present(filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline),
rating: filter.rating.rating == null ? const Optional.absent() : Optional.present(filter.rating.rating!),
isFavorite: filter.display.isFavorite ? const Optional.present(true) : const Optional.absent(),
isNotInAlbum: filter.display.isNotInAlbum ? const Optional.present(true) : const Optional.absent(),
personIds: Optional.present(filter.people.map((e) => e.id).toList()),
tagIds: filter.tagIds == null ? const Optional.absent() : Optional.present(filter.tagIds!),
type: type == null ? const Optional.absent() : Optional.present(type),
page: Optional.present(page),
size: const Optional.present(100),
query: filter.context,
queryAssetId: filter.assetId,
language: filter.language,
country: filter.location.country,
state: filter.location.state,
city: filter.location.city,
make: filter.camera.make,
model: filter.camera.model,
takenAfter: filter.date.takenAfter,
takenBefore: filter.date.takenBefore,
visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline,
rating: filter.rating.rating,
isFavorite: filter.display.isFavorite ? true : null,
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
personIds: filter.people.map((e) => e.id).toList(),
tagIds: filter.tagIds,
type: type,
page: page,
size: 100,
),
);
}
return _api.searchAssets(
MetadataSearchDto(
originalFileName: filter.filename != null && filter.filename!.isNotEmpty
? Optional.present(filter.filename!)
: const Optional.absent(),
country: filter.location.country == null ? const Optional.absent() : Optional.present(filter.location.country!),
description: filter.description != null && filter.description!.isNotEmpty
? Optional.present(filter.description!)
: const Optional.absent(),
ocr: filter.ocr != null && filter.ocr!.isNotEmpty ? Optional.present(filter.ocr!) : const Optional.absent(),
state: filter.location.state == null ? const Optional.absent() : Optional.present(filter.location.state!),
city: filter.location.city == null ? const Optional.absent() : Optional.present(filter.location.city!),
make: filter.camera.make == null ? const Optional.absent() : Optional.present(filter.camera.make!),
model: filter.camera.model == null ? const Optional.absent() : Optional.present(filter.camera.model!),
takenAfter: filter.date.takenAfter == null
? const Optional.absent()
: Optional.present(filter.date.takenAfter!),
takenBefore: filter.date.takenBefore == null
? const Optional.absent()
: Optional.present(filter.date.takenBefore!),
visibility: Optional.present(filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline),
rating: filter.rating.rating == null ? const Optional.absent() : Optional.present(filter.rating.rating!),
isFavorite: filter.display.isFavorite ? const Optional.present(true) : const Optional.absent(),
isNotInAlbum: filter.display.isNotInAlbum ? const Optional.present(true) : const Optional.absent(),
personIds: Optional.present(filter.people.map((e) => e.id).toList()),
tagIds: filter.tagIds == null ? const Optional.absent() : Optional.present(filter.tagIds!),
type: type == null ? const Optional.absent() : Optional.present(type),
page: Optional.present(page),
size: const Optional.present(1000),
originalFileName: filter.filename != null && filter.filename!.isNotEmpty ? filter.filename : null,
country: filter.location.country,
description: filter.description != null && filter.description!.isNotEmpty ? filter.description : null,
ocr: filter.ocr != null && filter.ocr!.isNotEmpty ? filter.ocr : null,
state: filter.location.state,
city: filter.location.city,
make: filter.camera.make,
model: filter.camera.model,
takenAfter: filter.date.takenAfter,
takenBefore: filter.date.takenBefore,
visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline,
rating: filter.rating.rating,
isFavorite: filter.display.isFavorite ? true : null,
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
personIds: filter.people.map((e) => e.id).toList(),
tagIds: filter.tagIds,
type: type,
page: page,
size: 1000,
),
);
}
@@ -20,7 +20,7 @@ class SyncApiRepository {
}
Future<void> deleteSyncAck(List<SyncEntityType> types) {
return _api.syncApi.deleteSyncAck(SyncAckDeleteDto(types: Optional.present(types)));
return _api.syncApi.deleteSyncAck(SyncAckDeleteDto(types: types));
}
Future<void> streamChanges(
@@ -29,7 +29,6 @@ class SyncApiRepository {
Function()? onReset,
int batchSize = kSyncEventBatchSize,
http.Client? httpClient,
Future<void>? abortSignal,
}) async {
final stopwatch = Stopwatch()..start();
final client = httpClient ?? NetworkRepository.client;
@@ -37,7 +36,7 @@ class SyncApiRepository {
final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'};
final request = http.AbortableRequest('POST', Uri.parse(endpoint), abortTrigger: abortSignal);
final request = http.Request('POST', Uri.parse(endpoint));
request.headers.addAll(headers);
request.body = jsonEncode(
SyncStreamDto(
@@ -91,7 +91,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
email: Value(user.email),
hasProfileImage: Value(user.hasProfileImage),
profileChangedAt: Value(user.profileChangedAt),
avatarColor: Value(user.avatarColor.orElse(null)?.toAvatarColor() ?? AvatarColor.primary),
avatarColor: Value(user.avatarColor?.toAvatarColor() ?? AvatarColor.primary),
isAdmin: Value(user.isAdmin),
pinCode: Value(user.pinCode),
quotaSizeInBytes: Value(user.quotaSizeInBytes ?? 0),
@@ -133,7 +133,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
email: Value(user.email),
hasProfileImage: Value(user.hasProfileImage),
profileChangedAt: Value(user.profileChangedAt),
avatarColor: Value(user.avatarColor.orElse(null)?.toAvatarColor() ?? AvatarColor.primary),
avatarColor: Value(user.avatarColor?.toAvatarColor() ?? AvatarColor.primary),
);
batch.insert(_db.userEntity, companion.copyWith(id: Value(user.id)), onConflict: DoUpdate((_) => companion));
@@ -5,24 +5,24 @@ import 'package:openapi/api.dart';
abstract final class ExifDtoConverter {
static ExifInfo fromDto(ExifResponseDto dto) {
return ExifInfo(
fileSize: dto.fileSizeInByte.orElse(null),
description: dto.description.orElse(null),
orientation: dto.orientation.orElse(null),
timeZone: dto.timeZone.orElse(null),
dateTimeOriginal: dto.dateTimeOriginal.orElse(null),
isFlipped: isOrientationFlipped(dto.orientation.orElse(null)),
latitude: dto.latitude.orElse(null)?.toDouble(),
longitude: dto.longitude.orElse(null)?.toDouble(),
city: dto.city.orElse(null),
state: dto.state.orElse(null),
country: dto.country.orElse(null),
make: dto.make.orElse(null),
model: dto.model.orElse(null),
lens: dto.lensModel.orElse(null),
f: dto.fNumber.orElse(null)?.toDouble(),
mm: dto.focalLength.orElse(null)?.toDouble(),
iso: dto.iso.orElse(null)?.toInt(),
exposureSeconds: exposureTimeToSeconds(dto.exposureTime.orElse(null)),
fileSize: dto.fileSizeInByte,
description: dto.description,
orientation: dto.orientation,
timeZone: dto.timeZone,
dateTimeOriginal: dto.dateTimeOriginal,
isFlipped: isOrientationFlipped(dto.orientation),
latitude: dto.latitude?.toDouble(),
longitude: dto.longitude?.toDouble(),
city: dto.city,
state: dto.state,
country: dto.country,
make: dto.make,
model: dto.model,
lens: dto.lensModel,
f: dto.fNumber?.toDouble(),
mm: dto.focalLength?.toDouble(),
iso: dto.iso?.toInt(),
exposureSeconds: exposureTimeToSeconds(dto.exposureTime),
);
}
@@ -40,7 +40,7 @@ abstract final class UserConverter {
updatedAt: DateTime.now(),
avatarColor: dto.avatarColor.toAvatarColor(),
memoryEnabled: false,
inTimeline: dto.inTimeline.orElse(null) ?? false,
inTimeline: dto.inTimeline ?? false,
isPartnerSharedBy: false,
isPartnerSharedWith: false,
profileChangedAt: dto.profileChangedAt,
-3
View File
@@ -24,7 +24,6 @@ import 'package:immich_mobile/pages/common/splash_screen.page.dart';
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
@@ -129,7 +128,6 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
case AppLifecycleState.resumed:
dPrint(() => "[APP STATE] resumed");
ref.read(appStateProvider.notifier).handleAppResume();
unawaited(ref.read(viewIntentHandlerProvider).onAppResumed());
break;
case AppLifecycleState.inactive:
dPrint(() => "[APP STATE] inactive");
@@ -235,7 +233,6 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
}
});
ref.read(viewIntentHandlerProvider).init();
ref.read(shareIntentUploadProvider.notifier).init();
}
@@ -73,10 +73,10 @@ class SharedLink {
slug = dto.slug,
type = dto.type == SharedLinkType.ALBUM ? SharedLinkSource.album : SharedLinkSource.individual,
title = dto.type == SharedLinkType.ALBUM
? dto.album.orElse(null)?.albumName.toUpperCase() ?? "UNKNOWN SHARE"
? dto.album?.albumName.toUpperCase() ?? "UNKNOWN SHARE"
: "INDIVIDUAL SHARE",
thumbAssetId = dto.type == SharedLinkType.ALBUM
? dto.album.orElse(null)?.albumThumbnailAssetId
? dto.album?.albumThumbnailAssetId
: dto.assets.isNotEmpty
? dto.assets[0].id
: null;
@@ -1,35 +0,0 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:path/path.dart';
extension ViewIntentPayloadX on ViewIntentPayload {
String get fileName {
final resolvedPath = path;
if (resolvedPath != null && resolvedPath.isNotEmpty) {
return basename(resolvedPath);
}
return localAssetId ?? 'view_intent_asset';
}
bool get isImage => mimeType.toLowerCase().startsWith('image/');
bool get isVideo => mimeType.toLowerCase().startsWith('video/');
AssetPlaybackStyle get playbackStyle {
if (isVideo) {
return AssetPlaybackStyle.video;
}
final normalizedMimeType = mimeType.toLowerCase();
if (normalizedMimeType == 'image/gif' || normalizedMimeType == 'image/webp') {
return AssetPlaybackStyle.imageAnimated;
}
final normalizedPath = path?.toLowerCase();
if (normalizedPath != null && (normalizedPath.endsWith('.gif') || normalizedPath.endsWith('.webp'))) {
return AssetPlaybackStyle.imageAnimated;
}
return AssetPlaybackStyle.image;
}
}
@@ -17,7 +17,6 @@ import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/theme/color_scheme.dart';
@@ -315,7 +314,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
final wsProvider = ref.read(websocketProvider.notifier);
final backgroundManager = ref.read(backgroundSyncProvider);
final backupProvider = ref.read(driftBackupProvider.notifier);
final viewIntentHandler = ref.read(viewIntentHandlerProvider);
unawaited(
ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then(
@@ -330,8 +328,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
backgroundManager.syncRemote().then((success) => syncSuccess = success),
]);
await viewIntentHandler.flushDeferredViewIntent();
if (syncSuccess) {
await Future.wait([
backgroundManager.hashAssets().then((_) {
@@ -11,7 +11,6 @@ import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/shared_link.provider.dart';
import 'package:immich_mobile/services/shared_link.service.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -366,10 +365,11 @@ class SharedLinkEditPage extends HookConsumerWidget {
bool? download;
bool? upload;
bool? meta;
var password = const Optional<String?>.absent();
var description = const Optional<String?>.absent();
String? desc;
String? password;
String? slug;
var expiry = const Optional<DateTime?>.absent();
DateTime? expiry;
bool? changeExpiry;
if (allowDownload.value != existingLink!.allowDownload) {
download = allowDownload.value;
@@ -383,16 +383,12 @@ class SharedLinkEditPage extends HookConsumerWidget {
meta = showMetadata.value;
}
if (descriptionController.text != (existingLink!.description ?? '')) {
description = descriptionController.text.isEmpty
? const Optional.present(null)
: Optional.present(descriptionController.text);
if (descriptionController.text != existingLink!.description) {
desc = descriptionController.text;
}
if (passwordController.text != (existingLink!.password ?? '')) {
password = passwordController.text.isEmpty
? const Optional.present(null)
: Optional.present(passwordController.text);
if (passwordController.text != existingLink!.password) {
password = passwordController.text;
}
if (slugController.text != (existingLink!.slug ?? "")) {
@@ -403,7 +399,8 @@ class SharedLinkEditPage extends HookConsumerWidget {
final newExpiry = expiryAfter.value;
if (newExpiry?.toUtc() != existingLink!.expiresAt?.toUtc()) {
expiry = newExpiry == null ? const Optional.present(null) : Optional.present(newExpiry.toUtc());
expiry = newExpiry;
changeExpiry = true;
}
await ref
@@ -413,10 +410,11 @@ class SharedLinkEditPage extends HookConsumerWidget {
showMeta: meta,
allowDownload: download,
allowUpload: upload,
description: description,
description: desc,
password: password,
slug: slug,
expiresAt: expiry,
expiresAt: expiry?.toUtc(),
changeExpiry: changeExpiry,
);
if (!context.mounted) {
return;
-14
View File
@@ -635,20 +635,6 @@ class NativeSyncApi {
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
}
Future<void> cancelSync() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelSync$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
}
Future<Map<String, List<PlatformAsset>>> getTrashedAssets() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$pigeonVar_messageChannelSuffix';
-191
View File
@@ -1,191 +0,0 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: unused_import, unused_shown_name
// ignore_for_file: type=lint
import 'dart:async';
import 'dart:typed_data' show Float64List, Int32List, Int64List;
import 'package:flutter/services.dart';
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
Object? _extractReplyValueOrThrow(List<Object?>? replyList, String channelName, {required bool isNullValid}) {
if (replyList == null) {
throw PlatformException(
code: 'channel-error',
message: 'Unable to establish connection on channel: "$channelName".',
);
} else if (replyList.length > 1) {
throw PlatformException(code: replyList[0]! as String, message: replyList[1] as String?, details: replyList[2]);
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
}
return replyList.firstOrNull;
}
bool _deepEquals(Object? a, Object? b) {
if (identical(a, b)) {
return true;
}
if (a is double && b is double) {
if (a.isNaN && b.isNaN) {
return true;
}
return a == 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) {
if (a.length != b.length) {
return false;
}
for (final MapEntry<Object?, Object?> entryA in a.entries) {
bool found = false;
for (final MapEntry<Object?, Object?> entryB in b.entries) {
if (_deepEquals(entryA.key, entryB.key)) {
if (_deepEquals(entryA.value, entryB.value)) {
found = true;
break;
} else {
return false;
}
}
}
if (!found) {
return false;
}
}
return true;
}
return a == b;
}
int _deepHash(Object? value) {
if (value is List) {
return Object.hashAll(value.map(_deepHash));
}
if (value is Map) {
int result = 0;
for (final MapEntry<Object?, Object?> entry in value.entries) {
result += (_deepHash(entry.key) * 31) ^ _deepHash(entry.value);
}
return result;
}
if (value is double && value.isNaN) {
// Normalize NaN to a consistent hash.
return 0x7FF8000000000000.hashCode;
}
if (value is double && value == 0.0) {
// Normalize -0.0 to 0.0 so they have the same hash code.
return 0.0.hashCode;
}
return value.hashCode;
}
class ViewIntentPayload {
ViewIntentPayload({this.path, required this.mimeType, this.localAssetId});
String? path;
String mimeType;
String? localAssetId;
List<Object?> _toList() {
return <Object?>[path, mimeType, localAssetId];
}
Object encode() {
return _toList();
}
static ViewIntentPayload decode(Object result) {
result as List<Object?>;
return ViewIntentPayload(
path: result[0] as String?,
mimeType: result[1]! as String,
localAssetId: result[2] as String?,
);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! ViewIntentPayload || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(path, other.path) &&
_deepEquals(mimeType, other.mimeType) &&
_deepEquals(localAssetId, other.localAssetId);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._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 ViewIntentPayload) {
buffer.putUint8(129);
writeValue(buffer, value.encode());
} else {
super.writeValue(buffer, value);
}
}
@override
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
case 129:
return ViewIntentPayload.decode(readValue(buffer)!);
default:
return super.readValueOfType(type, buffer);
}
}
}
class ViewIntentHostApi {
/// Constructor for [ViewIntentHostApi]. 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.
ViewIntentHostApi({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<ViewIntentPayload?> consumeViewIntent() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.ViewIntentHostApi.consumeViewIntent$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
return pigeonVar_replyValue as ViewIntentPayload?;
}
}
@@ -20,7 +20,6 @@ import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/remote_album_sliver_app_bar.dart';
import 'package:openapi/api.dart' show Optional;
@RoutePage()
class RemoteAlbumPage extends ConsumerStatefulWidget {
@@ -248,13 +247,10 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> {
try {
final newTitle = titleController.text.trim();
final newDescription = descriptionController.text.trim();
final description = newDescription.isEmpty
? const Optional<String?>.present(null)
: Optional<String?>.present(newDescription);
await ref
.read(remoteAlbumProvider.notifier)
.updateAlbum(widget.album.id, name: newTitle, description: description);
.updateAlbum(widget.album.id, name: newTitle, description: newDescription);
if (mounted) {
Navigator.of(
@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
@@ -11,9 +10,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/view_intent.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_ui/immich_ui.dart';
@@ -30,11 +26,7 @@ class UploadActionButton extends ConsumerWidget {
}
final isTimeline = source == ActionSource.timeline;
final viewerIntentFilePath = source == ActionSource.viewer ? ref.read(viewIntentFilePathProvider) : null;
List<LocalAsset>? assets;
var isUploadDialogOpen = false;
var wasUploadCancelled = false;
Future<void>? uploadDialogFuture;
if (source == ActionSource.timeline) {
assets = ref.read(multiSelectProvider).selectedAssets.whereType<LocalAsset>().toList();
@@ -43,50 +35,22 @@ class UploadActionButton extends ConsumerWidget {
}
ref.read(multiSelectProvider.notifier).reset();
} else {
isUploadDialogOpen = true;
uploadDialogFuture =
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) => _UploadProgressDialog(
onCancel: () {
wasUploadCancelled = true;
},
),
).whenComplete(() {
isUploadDialogOpen = false;
});
unawaited(uploadDialogFuture);
unawaited(
showDialog(
context: context,
barrierDismissible: false,
builder: (dialogContext) => const _UploadProgressDialog(),
),
);
}
var success = false;
if (!isTimeline && viewerIntentFilePath != null) {
final viewIntentService = ref.read(viewIntentServiceProvider);
viewIntentService.markUploadActive(viewerIntentFilePath);
var hasError = false;
try {
await ref
.read(foregroundUploadServiceProvider)
.uploadShareIntent(
[File(viewerIntentFilePath)],
onError: (_, _) {
hasError = true;
},
);
} finally {
await viewIntentService.markUploadInactive(viewerIntentFilePath);
}
success = !hasError;
} else {
final result = await ref.read(actionProvider.notifier).upload(source, assets: assets);
success = result.success;
}
final result = await ref.read(actionProvider.notifier).upload(source, assets: assets);
if (!isTimeline && context.mounted && isUploadDialogOpen) {
if (!isTimeline && context.mounted) {
Navigator.of(context, rootNavigator: true).pop();
}
if (context.mounted && !success && !wasUploadCancelled) {
if (context.mounted && !result.success) {
ImmichToast.show(
context: context,
msg: 'scaffold_body_error_occurred'.t(context: context),
@@ -109,9 +73,7 @@ class UploadActionButton extends ConsumerWidget {
}
class _UploadProgressDialog extends ConsumerWidget {
final VoidCallback onCancel;
const _UploadProgressDialog({required this.onCancel});
const _UploadProgressDialog();
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -141,8 +103,7 @@ class _UploadProgressDialog extends ConsumerWidget {
onPressed: () {
ref.read(manualUploadCancelTokenProvider)?.complete();
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
onCancel();
Navigator.of(context, rootNavigator: true).pop();
Navigator.of(context).pop();
},
labelText: 'cancel'.t(context: context),
),
@@ -21,7 +21,6 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
@@ -324,16 +323,14 @@ class _AssetPageState extends ConsumerState<AssetPage> {
required PhotoViewHeroAttributes? heroAttributes,
required bool isCurrent,
required bool isPlayingMotionVideo,
required String? localFilePath,
}) {
final size = context.sizeData;
final imageProvider = getFullImageProvider(asset, size: size, localFilePath: localFilePath);
if (asset.isImage && !isPlayingMotionVideo) {
return PhotoView(
key: Key(asset.heroTag),
index: widget.index,
imageProvider: imageProvider,
imageProvider: getFullImageProvider(asset, size: size),
heroAttributes: heroAttributes,
loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()),
gaplessPlayback: true,
@@ -380,9 +377,12 @@ class _AssetPageState extends ConsumerState<AssetPage> {
child: NativeVideoViewer(
key: _NativeVideoViewerKey(asset.heroTag),
asset: asset,
localFilePath: localFilePath,
isCurrent: isCurrent,
image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center),
image: Image(
image: getFullImageProvider(asset, size: size),
fit: BoxFit.contain,
alignment: Alignment.center,
),
),
);
}
@@ -393,7 +393,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
final timelineOrigin = ref.read(timelineServiceProvider).origin;
final asset = _asset;
if (asset == null) {
@@ -422,8 +421,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_scrollController.snapPosition.snapOffset = _snapOffset;
}
final viewIntentFilePath = timelineOrigin == TimelineOrigin.deepLink ? ref.watch(viewIntentFilePathProvider) : null;
return Stack(
children: [
SingleChildScrollView(
@@ -443,7 +440,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
: null,
isCurrent: isCurrent,
isPlayingMotionVideo: isPlayingMotionVideo,
localFilePath: viewIntentFilePath,
),
),
IgnorePointer(
@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -20,7 +19,6 @@ import 'package:native_video_player/native_video_player.dart';
class NativeVideoViewer extends ConsumerStatefulWidget {
final BaseAsset asset;
final String? localFilePath;
final bool isCurrent;
final bool showControls;
final Widget image;
@@ -28,7 +26,6 @@ class NativeVideoViewer extends ConsumerStatefulWidget {
const NativeVideoViewer({
super.key,
required this.asset,
this.localFilePath,
required this.image,
this.isCurrent = false,
this.showControls = true,
@@ -109,19 +106,6 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
}
try {
final localFilePath = widget.localFilePath;
if (localFilePath != null) {
final file = File(localFilePath);
if (!await file.exists()) {
throw Exception('No file found for the video');
}
return VideoSource.init(
path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path,
type: VideoSourceType.file,
);
}
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
final file = await StorageRepository().getFileForAsset(id);
@@ -1,4 +1,3 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:async/async.dart';
@@ -147,17 +146,10 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
}
}
ImageProvider getFullImageProvider(
BaseAsset asset, {
Size size = const Size(1080, 1920),
bool edited = true,
String? localFilePath,
}) {
ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920), bool edited = true}) {
// Create new provider and cache it
final ImageProvider provider;
if (localFilePath != null) {
provider = FileImage(File(localFilePath));
} else if (_shouldUseLocalAsset(asset)) {
if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage);
} else {
@@ -1,101 +1,101 @@
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/share_intent_service.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
((ref) => ShareIntentUploadStateNotifier(
ref.watch(appRouterProvider),
ref.read(foregroundUploadServiceProvider),
ref.read(shareIntentServiceProvider),
)),
);
class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttachment>> {
final AppRouter router;
final ForegroundUploadService _foregroundUploadService;
final ShareIntentService _shareIntentService;
final Logger _logger = Logger('ShareIntentUploadStateNotifier');
ShareIntentUploadStateNotifier(this.router, this._foregroundUploadService, this._shareIntentService) : super([]);
void init() {
_shareIntentService.onSharedMedia = onSharedMedia;
_shareIntentService.init();
}
void onSharedMedia(List<ShareIntentAttachment> attachments) {
router.removeWhere((route) => route.name == "ShareIntentRoute");
clearAttachments();
addAttachments(attachments);
router.push(ShareIntentRoute(attachments: attachments));
}
void addAttachments(List<ShareIntentAttachment> attachments) {
if (attachments.isEmpty) {
return;
}
state = [...state, ...attachments];
}
void removeAttachment(ShareIntentAttachment attachment) {
final updatedState = state.where((element) => element != attachment).toList();
if (updatedState.length != state.length) {
state = updatedState;
}
}
void clearAttachments() {
if (state.isEmpty) {
return;
}
state = [];
}
Future<void> uploadAll(List<File> files) async {
for (final file in files) {
final fileId = p.hash(file.path).toString();
_updateStatus(fileId, UploadStatus.running);
}
await _foregroundUploadService.uploadShareIntent(
files,
onProgress: (fileId, bytes, totalBytes) {
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
_updateProgress(fileId, progress);
},
onSuccess: (fileId, _) {
_updateStatus(fileId, UploadStatus.complete, progress: 1.0);
},
onError: (fileId, errorMessage) {
_logger.warning("Upload failed for file: $fileId, error: $errorMessage");
_updateStatus(fileId, UploadStatus.failed);
},
);
}
void _updateStatus(String fileId, UploadStatus status, {double? progress}) {
final id = int.parse(fileId);
state = [
for (final attachment in state)
if (attachment.id == id)
attachment.copyWith(status: status, uploadProgress: progress ?? attachment.uploadProgress)
else
attachment,
];
}
void _updateProgress(String fileId, double progress) {
final id = int.parse(fileId);
state = [
for (final attachment in state)
if (attachment.id == id) attachment.copyWith(uploadProgress: progress) else attachment,
];
}
}
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/share_intent_service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
((ref) => ShareIntentUploadStateNotifier(
ref.watch(appRouterProvider),
ref.read(foregroundUploadServiceProvider),
ref.read(shareIntentServiceProvider),
)),
);
class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttachment>> {
final AppRouter router;
final ForegroundUploadService _foregroundUploadService;
final ShareIntentService _shareIntentService;
final Logger _logger = Logger('ShareIntentUploadStateNotifier');
ShareIntentUploadStateNotifier(this.router, this._foregroundUploadService, this._shareIntentService) : super([]);
void init() {
_shareIntentService.onSharedMedia = onSharedMedia;
_shareIntentService.init();
}
void onSharedMedia(List<ShareIntentAttachment> attachments) {
router.removeWhere((route) => route.name == "ShareIntentRoute");
clearAttachments();
addAttachments(attachments);
router.push(ShareIntentRoute(attachments: attachments));
}
void addAttachments(List<ShareIntentAttachment> attachments) {
if (attachments.isEmpty) {
return;
}
state = [...state, ...attachments];
}
void removeAttachment(ShareIntentAttachment attachment) {
final updatedState = state.where((element) => element != attachment).toList();
if (updatedState.length != state.length) {
state = updatedState;
}
}
void clearAttachments() {
if (state.isEmpty) {
return;
}
state = [];
}
Future<void> uploadAll(List<File> files) async {
for (final file in files) {
final fileId = p.hash(file.path).toString();
_updateStatus(fileId, UploadStatus.running);
}
await _foregroundUploadService.uploadShareIntent(
files,
onProgress: (fileId, bytes, totalBytes) {
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
_updateProgress(fileId, progress);
},
onSuccess: (fileId) {
_updateStatus(fileId, UploadStatus.complete, progress: 1.0);
},
onError: (fileId, errorMessage) {
_logger.warning("Upload failed for file: $fileId, error: $errorMessage");
_updateStatus(fileId, UploadStatus.failed);
},
);
}
void _updateStatus(String fileId, UploadStatus status, {double? progress}) {
final id = int.parse(fileId);
state = [
for (final attachment in state)
if (attachment.id == id)
attachment.copyWith(status: status, uploadProgress: progress ?? attachment.uploadProgress)
else
attachment,
];
}
void _updateProgress(String fileId, double progress) {
final id = int.parse(fileId);
state = [
for (final attachment in state)
if (attachment.id == id) attachment.copyWith(uploadProgress: progress) else attachment,
];
}
}
@@ -36,12 +36,11 @@ class ActionResult {
final int count;
final bool success;
final String? error;
final List<String> remoteAssetIds;
const ActionResult({required this.count, required this.success, this.error, this.remoteAssetIds = const []});
const ActionResult({required this.count, required this.success, this.error});
@override
String toString() => 'ActionResult(count: $count, success: $success, error: $error, remoteAssetIds: $remoteAssetIds)';
String toString() => 'ActionResult(count: $count, success: $success, error: $error)';
}
class ActionNotifier extends Notifier<void> {
@@ -555,14 +554,10 @@ class ActionNotifier extends Notifier<void> {
final uploadedAssetIds = <String>{};
final failedAssetIds = <String>{};
final postUploadTasks = <Future<void>>[];
if (assetsToUpload.isEmpty) {
return const ActionResult(count: 0, success: false, error: 'No assets to upload');
}
final progressNotifier = ref.read(assetUploadProgressProvider.notifier);
final cancelToken = Completer<void>();
ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken;
final remoteAssetIds = <String>[];
// Initialize progress for all assets
for (final asset in assetsToUpload) {
@@ -579,7 +574,6 @@ class ActionNotifier extends Notifier<void> {
progressNotifier.setProgress(localAssetId, progress);
},
onSuccess: (localAssetId, remoteAssetId) {
remoteAssetIds.add(remoteAssetId);
progressNotifier.remove(localAssetId);
uploadedAssetIds.add(localAssetId);
final asset = assetById[localAssetId];
@@ -1,9 +1,8 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
/// Holds the isolate's cancellation signal.
final cancellationProvider = Provider<Completer<void>>(
/// Provider holding a boolean function that returns true when cancellation is requested.
/// A computation running in the isolate uses the function to implement cooperative cancellation.
final cancellationProvider = Provider<bool Function()>(
// This will be overridden in the isolate's container.
// Throwing ensures it's not used without an override.
(ref) => throw UnimplementedError(
@@ -8,7 +8,6 @@ import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:openapi/api.dart' show Optional;
import 'package:immich_mobile/providers/album/pending_album_uploads.provider.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
@@ -154,7 +153,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
Future<RemoteAlbum?> updateAlbum(
String albumId, {
String? name,
Optional<String?> description = const Optional.absent(),
String? description,
String? thumbnailAssetId,
bool? isActivityEnabled,
AlbumAssetOrder? order,
@@ -26,7 +26,7 @@ final syncStreamServiceProvider = Provider(
permissionRepository: ref.watch(permissionRepositoryProvider),
syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider),
api: ref.watch(apiServiceProvider),
cancellation: ref.watch(cancellationProvider),
cancelChecker: ref.watch(cancellationProvider),
),
);
@@ -42,7 +42,6 @@ final localSyncServiceProvider = Provider(
assetMediaRepository: ref.watch(assetMediaRepositoryProvider),
permissionRepository: ref.watch(permissionRepositoryProvider),
nativeSyncApi: ref.watch(nativeSyncApiProvider),
cancellation: ref.watch(cancellationProvider),
),
);
@@ -52,6 +51,5 @@ final hashServiceProvider = Provider(
localAssetRepository: ref.watch(localAssetRepository),
nativeSyncApi: ref.watch(nativeSyncApiProvider),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
cancellation: ref.watch(cancellationProvider),
),
);
@@ -29,7 +29,7 @@ final getAllPlacesProvider = FutureProvider.autoDispose<List<SearchCuratedConten
}
final curatedContent = assetPlaces
.map((data) => SearchCuratedContent(label: data.exifInfo.orElse(null)!.city.orElse(null)!, id: data.id))
.map((data) => SearchCuratedContent(label: data.exifInfo!.city!, id: data.id))
.toList();
return curatedContent;
@@ -1,31 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
class ViewIntentFilePathNotifier extends Notifier<String?> {
@override
String? build() => null;
void setPath(String path) {
if (state == path) {
return;
}
state = path;
}
void clear() {
if (state == null) {
return;
}
state = null;
}
void clearIfMatch(String path) {
if (state != path) {
return;
}
state = null;
}
}
final viewIntentFilePathProvider = NotifierProvider<ViewIntentFilePathNotifier, String?>(
ViewIntentFilePathNotifier.new,
);
@@ -1,23 +0,0 @@
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler_android.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler_stub.dart';
abstract class ViewIntentHandler {
void init();
Future<void> onAppResumed();
Future<void> flushDeferredViewIntent();
Future<void> handle(ViewIntentPayload attachment);
}
final viewIntentHandlerProvider = Provider<ViewIntentHandler>((ref) {
if (Platform.isAndroid) {
return AndroidViewIntentHandler(ref);
}
return const StubViewIntentHandler();
});
@@ -1,103 +0,0 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_pending.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/view_intent.service.dart';
import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart';
import 'package:logging/logging.dart';
class AndroidViewIntentHandler implements ViewIntentHandler {
final Ref _ref;
final ViewIntentService _viewIntentService;
final ViewIntentAssetResolver _viewIntentAssetResolver;
final AppRouter _router;
static final Logger _logger = Logger('ViewIntentHandler');
AndroidViewIntentHandler(Ref ref)
: _ref = ref,
_viewIntentService = ref.read(viewIntentServiceProvider),
_viewIntentAssetResolver = ref.read(viewIntentAssetResolverProvider),
_router = ref.watch(appRouterProvider);
@override
void init() {
// Covers cold start from a view intent before the first lifecycle "resumed".
unawaited(onAppResumed());
}
@override
Future<void> onAppResumed() => _checkForViewIntent();
@override
Future<void> flushDeferredViewIntent() => _flushPending();
Future<void> _checkForViewIntent() async {
final attachment = await _viewIntentService.consumeViewIntent();
if (attachment != null) {
await handle(attachment);
return;
}
if (_ref.read(viewIntentPendingProvider) == null) {
await _viewIntentService.cleanupStaleTempFiles();
}
}
Future<void> _flushPending() async {
final pendingAttachment = _ref.read(viewIntentPendingProvider.notifier).takeIfFresh();
_logger.info('flushPending, pendingAttachment:$pendingAttachment');
if (pendingAttachment != null) {
await handle(pendingAttachment);
}
}
@override
Future<void> handle(ViewIntentPayload attachment) async {
_logger.info(
'handle attachment, mimeType:${attachment.mimeType}, localAssetId=${attachment.localAssetId}, path=${attachment.path}, isAuthenticated:${_ref.read(authProvider).isAuthenticated}',
);
if (!_ref.read(authProvider).isAuthenticated) {
_ref.read(viewIntentPendingProvider.notifier).defer(attachment);
return;
}
final resolvedAsset = await _viewIntentAssetResolver.resolve(attachment);
_logger.fine('resolved view intent asset: ${resolvedAsset.asset}');
await _openAssetViewer(
resolvedAsset.asset,
resolvedAsset.timelineService,
viewIntentFilePath: resolvedAsset.viewIntentFilePath,
);
}
Future<void> _openAssetViewer(BaseAsset asset, TimelineService timelineService, {String? viewIntentFilePath}) async {
final notifier = _ref.read(assetViewerProvider.notifier);
notifier.reset();
if (asset.isVideo) {
notifier.setControls(false);
}
notifier.setAsset(asset);
if (viewIntentFilePath != null) {
_ref.read(viewIntentFilePathProvider.notifier).setPath(viewIntentFilePath);
unawaited(_viewIntentService.setManagedTempFilePath(viewIntentFilePath));
} else {
_ref.read(viewIntentFilePathProvider.notifier).clear();
unawaited(_viewIntentService.cleanupManagedTempFile());
}
await _router.replaceAll([
const TabShellRoute(),
AssetViewerRoute(initialIndex: 0, timelineService: timelineService),
]);
}
}
@@ -1,18 +0,0 @@
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
class StubViewIntentHandler implements ViewIntentHandler {
const StubViewIntentHandler();
@override
void init() {}
@override
Future<void> onAppResumed() async {}
@override
Future<void> flushDeferredViewIntent() async {}
@override
Future<void> handle(ViewIntentPayload attachment) async {}
}
@@ -1,39 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
final viewIntentNowProvider = Provider<DateTime Function()>((ref) => DateTime.now);
final viewIntentPendingProvider = NotifierProvider<ViewIntentPendingNotifier, ViewIntentPayload?>(
ViewIntentPendingNotifier.new,
);
class ViewIntentPendingNotifier extends Notifier<ViewIntentPayload?> {
static const _ttl = Duration(minutes: 10);
DateTime? _deferredAt;
@override
ViewIntentPayload? build() => null;
void defer(ViewIntentPayload attachment) {
_deferredAt = ref.read(viewIntentNowProvider)();
state = attachment;
}
ViewIntentPayload? takeIfFresh() {
final attachment = state;
final deferredAt = _deferredAt;
state = null;
_deferredAt = null;
if (attachment == null) {
return null;
}
if (deferredAt != null && ref.read(viewIntentNowProvider)().difference(deferredAt) > _ttl) {
return null;
}
return attachment;
}
}
@@ -23,8 +23,8 @@ class ActivityApiRepository extends ApiRepository {
final dto = ActivityCreateDto(
albumId: albumId,
type: type == ActivityType.comment ? ReactionType.comment : ReactionType.like,
assetId: assetId == null ? const Optional.absent() : Optional.present(assetId),
comment: comment == null ? const Optional.absent() : Optional.present(comment),
assetId: assetId,
comment: comment,
);
final response = await checkNull(_api.createActivity(dto));
return _toActivity(response);
@@ -45,6 +45,6 @@ class ActivityApiRepository extends ApiRepository {
type: dto.type == ReactionType.comment ? ActivityType.comment : ActivityType.like,
user: UserConverter.fromSimpleUserDto(dto.user),
assetId: dto.assetId,
comment: dto.comment.orElse(null),
comment: dto.comment,
);
}
@@ -24,7 +24,7 @@ class AssetApiRepository extends ApiRepository {
AssetApiRepository(this._api, this._stacksApi, this._trashApi);
Future<void> delete(List<String> ids, bool force) async {
return _api.deleteAssets(AssetBulkDeleteDto(ids: ids, force: Optional.present(force)));
return _api.deleteAssets(AssetBulkDeleteDto(ids: ids, force: force));
}
Future<void> restoreTrash(List<String> ids) async {
@@ -42,27 +42,19 @@ class AssetApiRepository extends ApiRepository {
}
Future<void> updateVisibility(List<String> ids, AssetVisibilityEnum visibility) async {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, visibility: Optional.present(_mapVisibility(visibility))));
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, visibility: _mapVisibility(visibility)));
}
Future<void> updateFavorite(List<String> ids, bool isFavorite) async {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, isFavorite: Optional.present(isFavorite)));
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, isFavorite: isFavorite));
}
Future<void> updateLocation(List<String> ids, LatLng location) async {
return _api.updateAssets(
AssetBulkUpdateDto(
ids: ids,
latitude: Optional.present(location.latitude),
longitude: Optional.present(location.longitude),
),
);
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, latitude: location.latitude, longitude: location.longitude));
}
Future<void> updateDateTime(List<String> ids, DateTime dateTime) async {
return _api.updateAssets(
AssetBulkUpdateDto(ids: ids, dateTimeOriginal: Optional.present(dateTime.toIso8601String())),
);
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, dateTimeOriginal: dateTime.toIso8601String()));
}
Future<StackResponse> stack(List<String> ids) async {
@@ -90,15 +82,15 @@ class AssetApiRepository extends ApiRepository {
final response = await checkNull(_api.getAssetInfo(assetId));
// we need to get the MIME of the thumbnail once that gets added to the API
return response.originalMimeType.orElse(null);
return response.originalMimeType;
}
Future<void> updateDescription(String assetId, String description) {
return _api.updateAsset(assetId, UpdateAssetDto(description: Optional.present(description)));
return _api.updateAsset(assetId, UpdateAssetDto(description: description));
}
Future<void> updateRating(String assetId, int rating) {
return _api.updateAsset(assetId, UpdateAssetDto(rating: Optional.present(rating)));
return _api.updateAsset(assetId, UpdateAssetDto(rating: rating));
}
Future<AssetEditsResponseDto?> editAsset(String assetId, List<AssetEdit> edits) {
@@ -13,7 +13,7 @@ class AuthApiRepository extends ApiRepository {
AuthApiRepository(this._apiService);
Future<void> changePassword(String newPassword) async {
await _apiService.usersApi.updateMyUser(UserUpdateMeDto(password: Optional.present(newPassword)));
await _apiService.usersApi.updateMyUser(UserUpdateMeDto(password: newPassword));
}
Future<LoginResponse> login(String email, String password) async {
@@ -46,7 +46,7 @@ class AuthApiRepository extends ApiRepository {
Future<bool> unlockPinCode(String pinCode) async {
try {
await _apiService.authenticationApi.unlockAuthSession(SessionUnlockDto(pinCode: Optional.present(pinCode)));
await _apiService.authenticationApi.unlockAuthSession(SessionUnlockDto(pinCode: pinCode));
return true;
} catch (_) {
return false;
@@ -22,13 +22,7 @@ class DriftAlbumApiRepository extends ApiRepository {
String? description,
}) async {
final responseDto = await checkNull(
_api.createAlbum(
CreateAlbumDto(
albumName: name,
description: description == null ? const Optional.absent() : Optional.present(description),
assetIds: Optional.present(assetIds.toList()),
),
),
_api.createAlbum(CreateAlbumDto(albumName: name, description: description, assetIds: assetIds.toList())),
);
return responseDto.toRemoteAlbum(owner);
@@ -47,14 +41,8 @@ class DriftAlbumApiRepository extends ApiRepository {
return (removed: removed, failed: failed);
}
Future<({List<String> added, List<String> failed})> addAssets(
String albumId,
Iterable<String> assetIds, {
Future<void>? abortTrigger,
}) async {
final response = await checkNull(
_api.addAssetsToAlbum(albumId, BulkIdsDto(ids: assetIds.toList()), abortTrigger: abortTrigger),
);
Future<({List<String> added, List<String> failed})> addAssets(String albumId, Iterable<String> assetIds) async {
final response = await checkNull(_api.addAssetsToAlbum(albumId, BulkIdsDto(ids: assetIds.toList())));
final List<String> added = [], failed = [];
for (final dto in response) {
if (dto.success) {
@@ -71,7 +59,7 @@ class DriftAlbumApiRepository extends ApiRepository {
String albumId,
UserDto owner, {
String? name,
Optional<String?> description = const Optional.absent(),
String? description,
String? thumbnailAssetId,
bool? isActivityEnabled,
AlbumAssetOrder? order,
@@ -85,13 +73,11 @@ class DriftAlbumApiRepository extends ApiRepository {
_api.updateAlbumInfo(
albumId,
UpdateAlbumDto(
albumName: name == null ? const Optional.absent() : Optional.present(name),
albumName: name,
description: description,
albumThumbnailAssetId: thumbnailAssetId == null
? const Optional.absent()
: Optional.present(thumbnailAssetId),
isActivityEnabled: isActivityEnabled == null ? const Optional.absent() : Optional.present(isActivityEnabled),
order: apiOrder == null ? const Optional.absent() : Optional.present(apiOrder),
albumThumbnailAssetId: thumbnailAssetId,
isActivityEnabled: isActivityEnabled,
order: apiOrder,
),
),
);
@@ -113,9 +99,7 @@ class DriftAlbumApiRepository extends ApiRepository {
}
Future<bool> setActivityStatus(String albumId, bool isEnabled) async {
final response = await checkNull(
_api.updateAlbumInfo(albumId, UpdateAlbumDto(isActivityEnabled: Optional.present(isEnabled))),
);
final response = await checkNull(_api.updateAlbumInfo(albumId, UpdateAlbumDto(isActivityEnabled: isEnabled)));
return response.isActivityEnabled;
}
}
@@ -132,7 +116,7 @@ extension on AlbumResponseDto {
updatedAt: updatedAt,
thumbnailAssetId: albumThumbnailAssetId,
isActivityEnabled: isActivityEnabled,
order: order.orElse(null) == AssetOrder.asc ? AlbumAssetOrder.asc : AlbumAssetOrder.desc,
order: order == AssetOrder.asc ? AlbumAssetOrder.asc : AlbumAssetOrder.desc,
assetCount: assetCount,
isShared: albumUsers.length > 2,
);
@@ -16,7 +16,7 @@ class PartnerApiRepository extends ApiRepository {
Future<List<UserDto>> getAll(Direction direction) async {
final response = await checkNull(
_api.getPartners(direction == Direction.sharedByMe ? PartnerDirection.sharedBy : PartnerDirection.sharedWith),
_api.getPartners(direction == Direction.sharedByMe ? PartnerDirection.by : PartnerDirection.with_),
);
return response.map(UserConverter.fromPartnerDto).toList();
}
@@ -18,10 +18,7 @@ class PersonApiRepository extends ApiRepository {
Future<PersonDto> update(String id, {String? name, DateTime? birthday}) async {
final birthdayUtc = birthday == null ? null : DateTime.utc(birthday.year, birthday.month, birthday.day);
final dto = PersonUpdateDto(
name: name == null ? const Optional.absent() : Optional.present(name),
birthDate: birthdayUtc == null ? const Optional.absent() : Optional.present(birthdayUtc),
);
final dto = PersonUpdateDto(name: name, birthDate: birthdayUtc);
final response = await checkNull(_api.updatePerson(id, dto));
return _toPerson(response);
}
@@ -15,13 +15,7 @@ class SessionsAPIRepository extends ApiRepository {
Future<SessionCreateResponse> createSession(String deviceType, String deviceOS, {int? duration}) async {
final dto = await checkNull(
_api.createSession(
SessionCreateDto(
deviceType: Optional.present(deviceType),
deviceOS: Optional.present(deviceOS),
duration: duration == null ? const Optional.absent() : Optional.present(duration),
),
),
_api.createSession(SessionCreateDto(deviceType: deviceType, deviceOS: deviceOS, duration: duration)),
);
return SessionCreateResponse(
@@ -29,7 +23,7 @@ class SessionsAPIRepository extends ApiRepository {
current: dto.current,
deviceType: deviceType,
deviceOS: deviceOS,
expiresAt: dto.expiresAt.orElse(null),
expiresAt: dto.expiresAt,
createdAt: dto.createdAt,
updatedAt: dto.updatedAt,
token: dto.token,
+1 -1
View File
@@ -55,7 +55,7 @@ class LockedGuard extends AutoRouteGuard {
return;
}
await _apiService.authenticationApi.unlockAuthSession(SessionUnlockDto(pinCode: Optional.present(securePinCode)));
await _apiService.authenticationApi.unlockAuthSession(SessionUnlockDto(pinCode: securePinCode));
resolver.next(true);
} on PlatformException catch (error) {
@@ -151,7 +151,7 @@ class ForegroundUploadService {
List<File> files, {
Completer<void>? cancelToken,
void Function(String fileId, int bytes, int totalBytes)? onProgress,
void Function(String fileId, String remoteAssetId)? onSuccess,
void Function(String fileId)? onSuccess,
void Function(String fileId, String errorMessage)? onError,
}) async {
if (files.isEmpty) {
@@ -171,7 +171,7 @@ class ForegroundUploadService {
);
if (result.isSuccess) {
onSuccess?.call(fileId, result.remoteAssetId!);
onSuccess?.call(fileId);
} else if (!result.isCancelled && result.errorMessage != null) {
onError?.call(fileId, result.errorMessage!);
}
+2 -6
View File
@@ -18,11 +18,7 @@ class OAuthService {
log.info("Starting OAuth flow with redirect URI: $redirectUri");
final dto = await _apiService.oAuthApi.startOAuth(
OAuthConfigDto(
redirectUri: redirectUri,
state: Optional.present(state),
codeChallenge: Optional.present(codeChallenge),
),
OAuthConfigDto(redirectUri: redirectUri, state: state, codeChallenge: codeChallenge),
);
final authUrl = dto?.url;
@@ -41,7 +37,7 @@ class OAuthService {
}
return await _apiService.oAuthApi.finishOAuth(
OAuthCallbackDto(url: result, state: Optional.present(state), codeVerifier: Optional.present(codeVerifier)),
OAuthCallbackDto(url: result, state: state, codeVerifier: codeVerifier),
);
}
}
+27 -25
View File
@@ -48,26 +48,26 @@ class SharedLinkService {
if (type == SharedLinkType.ALBUM) {
dto = SharedLinkCreateDto(
type: type,
albumId: albumId == null ? const Optional.absent() : Optional.present(albumId),
showMetadata: Optional.present(showMeta),
allowDownload: Optional.present(allowDownload),
allowUpload: Optional.present(allowUpload),
expiresAt: expiresAt == null ? const Optional.absent() : Optional.present(expiresAt),
description: description == null ? const Optional.absent() : Optional.present(description),
password: password == null ? const Optional.absent() : Optional.present(password),
slug: slug == null ? const Optional.absent() : Optional.present(slug),
albumId: albumId,
showMetadata: showMeta,
allowDownload: allowDownload,
allowUpload: allowUpload,
expiresAt: expiresAt,
description: description,
password: password,
slug: slug,
);
} else if (assetIds != null) {
dto = SharedLinkCreateDto(
type: type,
showMetadata: Optional.present(showMeta),
allowDownload: Optional.present(allowDownload),
allowUpload: Optional.present(allowUpload),
expiresAt: expiresAt == null ? const Optional.absent() : Optional.present(expiresAt),
description: description == null ? const Optional.absent() : Optional.present(description),
password: password == null ? const Optional.absent() : Optional.present(password),
slug: slug == null ? const Optional.absent() : Optional.present(slug),
assetIds: Optional.present(assetIds),
showMetadata: showMeta,
allowDownload: allowDownload,
allowUpload: allowUpload,
expiresAt: expiresAt,
description: description,
password: password,
slug: slug,
assetIds: assetIds,
);
}
@@ -88,22 +88,24 @@ class SharedLinkService {
required bool? showMeta,
required bool? allowDownload,
required bool? allowUpload,
Optional<String?> password = const Optional.absent(),
Optional<String?> description = const Optional.absent(),
bool? changeExpiry = false,
String? description,
String? password,
String? slug,
Optional<DateTime?> expiresAt = const Optional.absent(),
DateTime? expiresAt,
}) async {
try {
final responseDto = await _apiService.sharedLinksApi.updateSharedLink(
id,
SharedLinkEditDto(
showMetadata: showMeta == null ? const Optional.absent() : Optional.present(showMeta),
allowDownload: allowDownload == null ? const Optional.absent() : Optional.present(allowDownload),
allowUpload: allowUpload == null ? const Optional.absent() : Optional.present(allowUpload),
password: password,
description: description,
showMetadata: showMeta,
allowDownload: allowDownload,
allowUpload: allowUpload,
expiresAt: expiresAt,
slug: slug == null ? const Optional.absent() : Optional.present(slug),
description: description,
password: password,
slug: slug,
changeExpiryTime: changeExpiry,
),
);
if (responseDto != null) {
@@ -1,108 +0,0 @@
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
final viewIntentServiceProvider = Provider((ref) => ViewIntentService(ViewIntentHostApi()));
class ViewIntentService {
final ViewIntentHostApi _viewIntentHostApi;
final Future<Directory> Function() _temporaryDirectory;
String? _managedTempFilePath;
final Set<String> _activeUploadPaths = {};
ViewIntentService(this._viewIntentHostApi, {Future<Directory> Function()? temporaryDirectory})
: _temporaryDirectory = temporaryDirectory ?? getTemporaryDirectory;
Future<ViewIntentPayload?> consumeViewIntent() async {
try {
return await _viewIntentHostApi.consumeViewIntent();
} catch (_) {
// Ignore errors - view intent might not be present
return null;
}
}
Future<void> setManagedTempFilePath(String path) async {
final previous = _managedTempFilePath;
if (previous == path) {
return;
}
_managedTempFilePath = path;
if (previous != null) {
await cleanupTempFile(previous);
}
}
Future<void> cleanupManagedTempFile() async {
final path = _managedTempFilePath;
_managedTempFilePath = null;
if (path != null) {
await cleanupTempFile(path);
}
}
Future<void> cleanupManagedTempFileIfCurrent(String path) async {
if (_managedTempFilePath == path) {
_managedTempFilePath = null;
}
await cleanupTempFile(path);
}
Future<void> cleanupTempFile(String path) async {
if (!_isManagedTempFile(path)) {
return;
}
if (_activeUploadPaths.contains(path)) {
return;
}
try {
final file = File(path);
if (await file.exists()) {
await file.delete();
}
} catch (_) {
// Best-effort cleanup only.
}
}
Future<void> cleanupStaleTempFiles() async {
try {
final tempDirectory = await _temporaryDirectory();
await for (final entity in tempDirectory.list()) {
if (entity is! File) {
continue;
}
final path = entity.path;
if (!_isManagedTempFile(path) || path == _managedTempFilePath || _activeUploadPaths.contains(path)) {
continue;
}
await entity.delete();
}
} catch (_) {
// Best-effort cleanup only.
}
}
void markUploadActive(String path) {
_activeUploadPaths.add(path);
}
Future<void> markUploadInactive(String path) async {
if (!_activeUploadPaths.remove(path)) {
return;
}
if (_managedTempFilePath != path) {
await cleanupTempFile(path);
}
}
bool _isManagedTempFile(String path) {
return p.basename(path).startsWith('view_intent_') && p.basename(p.dirname(path)) == 'cache';
}
}
@@ -1,65 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/models/view_intent/view_intent_payload.extension.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:logging/logging.dart';
class ViewIntentResolvedAsset {
final BaseAsset asset;
final TimelineService timelineService;
final String? viewIntentFilePath;
const ViewIntentResolvedAsset({required this.asset, required this.timelineService, this.viewIntentFilePath});
}
final viewIntentAssetResolverProvider = Provider<ViewIntentAssetResolver>(
(ref) => ViewIntentAssetResolver(
localAssetRepository: ref.read(localAssetRepository),
timelineFactory: ref.read(timelineFactoryProvider),
),
);
class ViewIntentAssetResolver {
final DriftLocalAssetRepository _localAssetRepository;
final TimelineFactory _timelineFactory;
static final Logger _logger = Logger('ViewIntentAssetResolver');
const ViewIntentAssetResolver({required this._localAssetRepository, required this._timelineFactory});
Future<ViewIntentResolvedAsset> resolve(ViewIntentPayload attachment) async {
final localAssetId = attachment.localAssetId;
final path = attachment.path;
_logger.fine('resolve start, localAssetId=$localAssetId, path=$path, mimeType=${attachment.mimeType}');
if (localAssetId == null && path == null) {
throw StateError('ViewIntent resolution requires either a localAssetId or a materialized file path.');
}
final localAsset = localAssetId != null ? await _localAssetRepository.getById(localAssetId) : null;
final asset = localAsset ?? _toTransientAsset(attachment);
return ViewIntentResolvedAsset(
asset: asset,
timelineService: _timelineFactory.fromAssets([asset], TimelineOrigin.deepLink),
viewIntentFilePath: localAsset == null ? path : null,
);
}
LocalAsset _toTransientAsset(ViewIntentPayload attachment) {
final now = DateTime.now();
return LocalAsset(
id: attachment.localAssetId ?? '-${attachment.path!.hashCode.abs()}',
name: attachment.fileName,
type: attachment.isVideo ? AssetType.video : AssetType.image,
createdAt: now,
updatedAt: now,
isEdited: false,
playbackStyle: attachment.playbackStyle,
);
}
}
+45 -21
View File
@@ -8,9 +8,10 @@ import 'package:immich_mobile/entities/store.entity.dart';
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/wm_executor.dart';
import 'package:logging/logging.dart';
import 'package:worker_manager/worker_manager.dart' show Cancelable;
import 'package:worker_manager/worker_manager.dart';
class InvalidIsolateUsageException implements Exception {
const InvalidIsolateUsageException();
@@ -29,27 +30,50 @@ Cancelable<T?> runInIsolateGentle<T>({
throw const InvalidIsolateUsageException();
}
return workerManagerPatch.executeGentle((onCancel) async {
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
DartPluginRegistrant.ensureInitialized();
return workerManagerPatch.executeGentle((cancelledChecker) async {
T? result;
await runZonedGuarded(
() async {
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
DartPluginRegistrant.ensureInitialized();
final log = Logger("IsolateLogger");
final (drift, logDb) = await Bootstrap.initDomain(shouldBufferLogs: false, listenStoreUpdates: false);
final ref = ProviderContainer(
overrides: [cancellationProvider.overrideWithValue(onCancel), driftProvider.overrideWith(driftOverride(drift))],
final (drift, logDb) = await Bootstrap.initDomain(shouldBufferLogs: false, listenStoreUpdates: false);
final ref = ProviderContainer(
overrides: [
cancellationProvider.overrideWithValue(cancelledChecker),
driftProvider.overrideWith(driftOverride(drift)),
],
);
Logger log = Logger("IsolateLogger");
try {
result = await computation(ref);
} on CanceledError {
log.warning("Computation cancelled ${debugLabel == null ? '' : ' for $debugLabel'}");
} catch (error, stack) {
log.severe("Error in runInIsolateGentle ${debugLabel == null ? '' : ' for $debugLabel'}", error, stack);
} finally {
try {
ref.dispose();
await Store.dispose();
await LogService.I.dispose();
await logDb.close();
await drift.close();
} catch (error, stack) {
dPrint(() => "Error closing resources in isolate: $error, $stack");
} finally {
ref.dispose();
// Delay to ensure all resources are released
await Future.delayed(const Duration(seconds: 2));
}
}
},
(error, stack) {
dPrint(() => "Error in isolate $debugLabel zone: $error, $stack");
},
);
try {
return await computation(ref);
} catch (error, stack) {
log.severe("Error in runInIsolateGentle${debugLabel == null ? '' : ' for $debugLabel'}", error, stack);
return null;
} finally {
ref.dispose();
await Store.dispose();
await LogService.I.dispose();
await logDb.close();
await drift.close();
}
return result;
});
}
-163
View File
@@ -1,163 +0,0 @@
// Forked from worker_manager's `WorkerImpl` (src/worker/worker_io.dart): a
// `CancelRequest` completes the computation's [Completer] (so it can await
// cancellation and unwind) instead of flipping a polled flag, and [shutdown]
// lets the isolate drain and exit on its own rather than force-killing it. Only
// the gentle-with-cancellation path immich uses is kept.
//
// ignore_for_file: implementation_imports
import 'dart:async';
import 'dart:isolate';
import 'package:worker_manager/src/scheduling/task.dart';
import 'package:worker_manager/src/worker/cancel_request.dart';
import 'package:worker_manager/src/worker/result.dart';
/// A worker computation that receives a [Completer] which completes on
/// cancellation: await its future to react promptly, or read `isCompleted`.
typedef GentleExecution<R> = FutureOr<R> Function(Completer<void> onCancel);
class _Shutdown {
const _Shutdown();
}
class IsolateWorker {
IsolateWorker();
Isolate? _isolate;
RawReceivePort? _receivePort;
SendPort? _sendPort;
Completer<void>? _sendPortReceived;
Completer? _result;
String? taskId;
bool get initialized => _sendPortReceived?.isCompleted ?? false;
bool get initializing {
final sendPortReceived = _sendPortReceived;
return sendPortReceived != null && !sendPortReceived.isCompleted;
}
Future<void> initialize() async {
final sendPortReceived = _sendPortReceived = Completer<void>();
final receivePort = _receivePort = RawReceivePort();
receivePort.handler = (Object message) {
if (message is SendPort) {
_sendPort = message;
sendPortReceived.complete();
} else if (message is ResultSuccess) {
_result?.complete(message.value);
_afterTask();
} else if (message is ResultError) {
_result?.completeError(message.error, message.stackTrace);
_afterTask();
}
};
_isolate = await Isolate.spawn(_isolateEntry, receivePort.sendPort, errorsAreFatal: false);
await sendPortReceived.future;
}
Future<R> work<R>(Task<R> task) async {
taskId = task.id;
final result = _result = Completer();
_sendPort!.send(task.execution);
return await (result.future as Future<R>);
}
/// Cancels the current task without retiring the worker.
void cancelGentle() => _sendPort?.send(CancelRequest());
/// Cancels any in-flight task and awaits the isolate exiting on its own no
/// force-kill, so `finally` blocks and native cleanup always run.
///
/// Detaches the slot up front so a concurrent [initialize] can revive it
/// without colliding (revival installs fresh ports while this drains the ones
/// it captured locally). A revived worker is always idle, so the still-live
/// receive-port handler can't misroute a result.
Future<void> shutdown() async {
final sendPortReceived = _sendPortReceived;
if (sendPortReceived != null && !sendPortReceived.isCompleted) {
await sendPortReceived.future;
}
final isolate = _isolate;
final receivePort = _receivePort;
final sendPort = _sendPort;
if (isolate == null || receivePort == null || sendPort == null) {
return;
}
_isolate = null;
_sendPort = null;
_sendPortReceived = null;
// Not _result: an in-flight task still delivers it before exiting; nulling
// here would drop that and hang work()'s caller.
final exited = Completer<void>();
final exitPort = RawReceivePort();
exitPort.handler = (_) {
if (!exited.isCompleted) {
exited.complete();
}
exitPort.close();
};
isolate.addOnExitListener(exitPort.sendPort);
sendPort.send(const _Shutdown());
await exited.future;
receivePort.close();
}
void _afterTask() {
taskId = null;
_result = null;
}
static void _isolateEntry(SendPort sendPort) {
final receivePort = RawReceivePort();
sendPort.send(receivePort.sendPort);
// One task at a time, so a single completer suffices; null between tasks.
Completer<void>? onCancel;
void cancel() {
if (onCancel?.isCompleted == false) {
onCancel!.complete();
}
}
var shuttingDown = false;
var running = false;
receivePort.handler = (message) async {
if (message is _Shutdown) {
shuttingDown = true;
cancel();
if (!running) {
Isolate.exit();
}
return;
}
if (message is CancelRequest) {
cancel();
return;
}
final execution = message as GentleExecution;
onCancel = Completer<void>();
running = true;
Result result;
try {
result = ResultSuccess(await execution(onCancel!));
} catch (error, stackTrace) {
result = ResultError(error, stackTrace);
} finally {
onCancel = null;
running = false;
}
if (shuttingDown) {
// An isolate that has used platform channels can't exit on its own (Flutter's BackgroundIsolateBinaryMessenger
// opens an undisposable port), so closing our ports isn't enough. Isolate.exit delivers the result as its final
// message and terminates. It's abrupt (skips pending finally/microtasks) but safe here: the computation and its
// `finally` are already done and there's no await before this, so nothing pending is skipped.
Isolate.exit(sendPort, result);
}
sendPort.send(result);
};
}
}
+63 -52
View File
@@ -1,58 +1,69 @@
import 'package:flutter/foundation.dart';
import 'package:openapi/api.dart';
abstract interface class _Dynamic {
Object? resolve();
}
class _CurrentTimestamp implements _Dynamic {
const _CurrentTimestamp();
@override
Object? resolve() => DateTime.now().toIso8601String();
}
const _now = _CurrentTimestamp();
@visibleForTesting
final Map<String, Map<String, Object?>> openApiPatches = {
'UserPreferencesResponseDto': {
'download.includeEmbeddedVideos': false,
'folders': FoldersResponse(enabled: false, sidebarWeb: false).toJson(),
'memories': MemoriesResponse(enabled: true, duration: 5).toJson(),
'ratings': RatingsResponse(enabled: false).toJson(),
'people': PeopleResponse(enabled: true, sidebarWeb: false).toJson(),
'tags': TagsResponse(enabled: false, sidebarWeb: false).toJson(),
'sharedLinks': SharedLinksResponse(enabled: true, sidebarWeb: false).toJson(),
'cast': CastResponse(gCastEnabled: false).toJson(),
'albums': {'defaultAssetOrder': 'desc'},
},
'ServerConfigDto': {
'mapLightStyleUrl': 'https://tiles.immich.cloud/v1/style/light.json',
'mapDarkStyleUrl': 'https://tiles.immich.cloud/v1/style/dark.json',
'minFaces': 3,
},
'UserResponseDto': {'profileChangedAt': _now},
'AssetResponseDto': {'visibility': 'timeline', 'createdAt': _now, 'isEdited': false},
'UserAdminResponseDto': {'profileChangedAt': _now},
'LoginResponseDto': {'isOnboarded': false},
'SyncUserV1': {'profileChangedAt': _now, 'hasProfileImage': false},
'SyncAssetV1': {'isEdited': false},
'ServerFeaturesDto': {'ocr': false, 'realtimeTranscoding': false},
'MemoriesResponse': {'duration': 5},
};
void upgradeDto(dynamic value, String targetType) {
if (value is! Map) {
return;
dynamic upgradeDto(dynamic value, String targetType) {
switch (targetType) {
case 'UserPreferencesResponseDto':
if (value is Map) {
addDefault(value, 'download.includeEmbeddedVideos', false);
addDefault(value, 'folders', FoldersResponse(enabled: false, sidebarWeb: false).toJson());
addDefault(value, 'memories', MemoriesResponse(enabled: true, duration: 5).toJson());
addDefault(value, 'ratings', RatingsResponse(enabled: false).toJson());
addDefault(value, 'people', PeopleResponse(enabled: true, sidebarWeb: false).toJson());
addDefault(value, 'tags', TagsResponse(enabled: false, sidebarWeb: false).toJson());
addDefault(value, 'sharedLinks', SharedLinksResponse(enabled: true, sidebarWeb: false).toJson());
addDefault(value, 'cast', CastResponse(gCastEnabled: false).toJson());
addDefault(value, 'albums', {'defaultAssetOrder': 'desc'});
}
break;
case 'ServerConfigDto':
if (value is Map) {
addDefault(value, 'mapLightStyleUrl', 'https://tiles.immich.cloud/v1/style/light.json');
addDefault(value, 'mapDarkStyleUrl', 'https://tiles.immich.cloud/v1/style/dark.json');
addDefault(value, 'minFaces', 3);
}
case 'UserResponseDto':
if (value is Map) {
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
}
break;
case 'AssetResponseDto':
if (value is Map) {
addDefault(value, 'visibility', 'timeline');
addDefault(value, 'createdAt', DateTime.now().toIso8601String());
addDefault(value, 'isEdited', false);
}
break;
case 'UserAdminResponseDto':
if (value is Map) {
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
}
break;
case 'LoginResponseDto':
if (value is Map) {
addDefault(value, 'isOnboarded', false);
}
break;
case 'SyncUserV1':
if (value is Map) {
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
addDefault(value, 'hasProfileImage', false);
}
case 'SyncAssetV1':
if (value is Map) {
addDefault(value, 'isEdited', false);
}
case 'ServerFeaturesDto':
if (value is Map) {
addDefault(value, 'ocr', false);
addDefault(value, 'realtimeTranscoding', false);
}
break;
case 'MemoriesResponse':
if (value is Map) {
addDefault(value, 'duration', 5);
}
break;
}
final fields = openApiPatches[targetType];
if (fields == null) {
return;
}
fields.forEach((key, defaultValue) {
addDefault(value, key, defaultValue is _Dynamic ? defaultValue.resolve() : defaultValue);
});
}
addDefault(dynamic value, String keys, dynamic defaultValue) {
@@ -21,7 +21,6 @@ import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/oauth.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -183,11 +182,9 @@ class LoginForm extends HookConsumerWidget {
Future<void> handleSyncFlow() async {
final backgroundManager = ref.read(backgroundSyncProvider);
final viewIntentHandler = ref.read(viewIntentHandlerProvider);
await backgroundManager.syncLocal(full: true);
await backgroundManager.syncRemote();
await viewIntentHandler.flushDeferredViewIntent();
await backgroundManager.hashAssets();
if (SettingsRepository.instance.appConfig.backup.syncAlbums) {
@@ -262,7 +259,7 @@ class LoginForm extends HookConsumerWidget {
}
unawaited(handleSyncFlow());
ref.read(websocketProvider.notifier).connect();
unawaited(context.router.replaceAll([const TabShellRoute()]));
unawaited(context.replaceRoute(const TabShellRoute()));
return;
}
} catch (error) {
@@ -349,7 +346,7 @@ class LoginForm extends HookConsumerWidget {
await getManageMediaPermission();
}
unawaited(handleSyncFlow());
unawaited(context.router.replaceAll([const TabShellRoute()]));
unawaited(context.replaceRoute(const TabShellRoute()));
return;
}
} catch (error, stack) {
+107 -29
View File
@@ -6,8 +6,8 @@ import 'dart:math';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/utils/isolate_worker.dart';
import 'package:worker_manager/src/number_of_processors/processors_io.dart';
import 'package:worker_manager/src/worker/worker.dart';
import 'package:worker_manager/worker_manager.dart';
final workerManagerPatch = _Executor();
@@ -16,13 +16,6 @@ final workerManagerPatch = _Executor();
const _minId = -9007199254740992;
const _maxId = 9007199254740992;
class _GentleTask<R> extends Task<R> implements Gentle {
@override
final GentleExecution<R> execution;
_GentleTask({required super.id, required super.completer, required super.workPriority, required this.execution});
}
class Mixinable<T> {
late final itSelf = this as T;
}
@@ -58,13 +51,13 @@ mixin _ExecutorLogger on Mixinable<_Executor> {
class _Executor extends Mixinable<_Executor> with _ExecutorLogger {
final _queue = PriorityQueue<Task>();
final _pool = <IsolateWorker>[];
final _pool = <Worker>[];
var _nextTaskId = _minId;
var _dynamicSpawning = false;
var _isolatesCount = numberOfProcessors;
@visibleForTesting
UnmodifiableListView<IsolateWorker> get pool => UnmodifiableListView(_pool);
UnmodifiableListView<Worker> get pool => UnmodifiableListView(_pool);
@override
Future<void> init({int? isolatesCount, bool? dynamicSpawning}) async {
@@ -87,30 +80,71 @@ class _Executor extends Mixinable<_Executor> with _ExecutorLogger {
@override
Future<void> dispose() async {
_queue.clear();
final shutdown = _pool.map((worker) => worker.shutdown()).toList(growable: false);
for (final worker in _pool) {
if (worker.initialized || worker.initializing) {
worker.kill();
}
}
_pool.clear();
await Future.wait(shutdown);
super.dispose();
}
/// Runs [execution] on a worker isolate; its [Completer] completes when the
/// returned [Cancelable] is cancelled.
Cancelable<R> executeGentle<R>(GentleExecution<R> execution, {WorkPriority priority = WorkPriority.immediately}) {
if (_nextTaskId + 1 == _maxId) {
_nextTaskId = _minId;
Cancelable<R> execute<R>(Execute<R> execution, {WorkPriority priority = WorkPriority.immediately}) {
return _createCancelable<R>(execution: execution, priority: priority);
}
Cancelable<R> executeNow<R>(ExecuteGentle<R> execution) {
final task = TaskGentle<R>(
id: "",
workPriority: WorkPriority.immediately,
execution: execution,
completer: Completer<R>(),
);
Future<void> run() async {
try {
final result = await execution(() => task.canceled);
task.complete(result, null, null);
} catch (error, st) {
task.complete(null, error, st);
}
}
final id = _nextTaskId.toString();
_nextTaskId++;
final task = _GentleTask<R>(id: id, workPriority: priority, execution: execution, completer: Completer<R>());
_queue.add(task);
_schedule();
logTaskAdded(task.id);
run();
return Cancelable(completer: task.completer, onCancel: () => _cancel(task));
}
Cancelable<R> executeWithPort<R, T>(
ExecuteWithPort<R> execution, {
WorkPriority priority = WorkPriority.immediately,
required void Function(T value) onMessage,
}) {
return _createCancelable<R>(
execution: execution,
priority: priority,
onMessage: (message) => onMessage(message as T),
);
}
Cancelable<R> executeGentle<R>(ExecuteGentle<R> execution, {WorkPriority priority = WorkPriority.immediately}) {
return _createCancelable<R>(execution: execution, priority: priority);
}
Cancelable<R> executeGentleWithPort<R, T>(
ExecuteGentleWithPort<R> execution, {
WorkPriority priority = WorkPriority.immediately,
required void Function(T value) onMessage,
}) {
return _createCancelable<R>(
execution: execution,
priority: priority,
onMessage: (message) => onMessage(message as T),
);
}
void _createWorkers() {
for (var i = 0; i < _isolatesCount; i++) {
_pool.add(IsolateWorker());
_pool.add(Worker());
}
}
@@ -118,6 +152,45 @@ class _Executor extends Mixinable<_Executor> with _ExecutorLogger {
await Future.wait(_pool.map((e) => e.initialize()));
}
Cancelable<R> _createCancelable<R>({
required Function execution,
WorkPriority priority = WorkPriority.immediately,
void Function(Object value)? onMessage,
}) {
if (_nextTaskId + 1 == _maxId) {
_nextTaskId = _minId;
}
final id = _nextTaskId.toString();
_nextTaskId++;
late final Task<R> task;
final completer = Completer<R>();
if (execution is ExecuteWithPort<R>) {
task = TaskWithPort<R>(
id: id,
workPriority: priority,
execution: execution,
completer: completer,
onMessage: onMessage!,
);
} else if (execution is ExecuteGentle<R>) {
task = TaskGentle<R>(id: id, workPriority: priority, execution: execution, completer: completer);
} else if (execution is ExecuteGentleWithPort<R>) {
task = TaskGentleWithPort<R>(
id: id,
workPriority: priority,
execution: execution,
completer: completer,
onMessage: onMessage!,
);
} else if (execution is Execute<R>) {
task = TaskRegular<R>(id: id, workPriority: priority, execution: execution, completer: completer);
}
_queue.add(task);
_schedule();
logTaskAdded(task.id);
return Cancelable(completer: task.completer, onCancel: () => _cancel(task));
}
Future<void> _ensureWorkersInitialized() async {
if (_pool.isEmpty) {
_createWorkers();
@@ -167,9 +240,7 @@ class _Executor extends Mixinable<_Executor> with _ExecutorLogger {
)
.whenComplete(() {
if (_dynamicSpawning && _queue.isEmpty) {
// Retire the idle worker; shutdown() nulls its fields so the husk
// stays pooled and is revived by initialize() if work arrives.
unawaited(availableWorker.shutdown());
availableWorker.kill();
}
_schedule();
});
@@ -179,8 +250,15 @@ class _Executor extends Mixinable<_Executor> with _ExecutorLogger {
void _cancel(Task task) {
task.cancel();
_queue.remove(task);
// All tasks are gentle: signal cancellation; the worker unwinds on its own.
_pool.firstWhereOrNull((worker) => worker.taskId == task.id)?.cancelGentle();
final targetWorker = _pool.firstWhereOrNull((worker) => worker.taskId == task.id);
if (task is Gentle) {
targetWorker?.cancelGentle();
} else {
targetWorker?.kill();
if (!_dynamicSpawning) {
targetWorker?.initialize();
}
}
super._cancel(task);
}
}
-89
View File
@@ -1,89 +0,0 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools."aqua:flutter/flutter"]]
version = "3.44.1"
backend = "aqua:flutter/flutter"
[tools."aqua:flutter/flutter"."platforms.linux-arm64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.1-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-arm64-musl"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.1-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.1-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-x64-musl"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.1-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.macos-arm64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.44.1-stable.zip"
[tools."aqua:flutter/flutter"."platforms.macos-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_3.44.1-stable.zip"
[tools."aqua:flutter/flutter"."platforms.windows-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_3.44.1-stable.zip"
[[tools."github:CQLabs/homebrew-dcm"]]
version = "1.37.0"
backend = "github:CQLabs/homebrew-dcm"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-arm64"]
checksum = "sha256:253da2512b149913dfe345bf9a62a79acb2d730f66e71162ba4a92dfc4224b82"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543838"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-arm64-musl"]
checksum = "sha256:253da2512b149913dfe345bf9a62a79acb2d730f66e71162ba4a92dfc4224b82"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543838"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64"]
checksum = "sha256:477e086d4099c12f21e5ccd83b005d5fb945dd4cac4fd127fd9a08d7649af1cf"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543797"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64-musl"]
checksum = "sha256:477e086d4099c12f21e5ccd83b005d5fb945dd4cac4fd127fd9a08d7649af1cf"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543797"
[tools."github:CQLabs/homebrew-dcm"."platforms.macos-arm64"]
checksum = "sha256:30bede64367d09067093cc57af6ec9496d7717898138ded5cb98a16ac8dd9d93"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-macos-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543757"
[tools."github:CQLabs/homebrew-dcm"."platforms.macos-x64"]
checksum = "sha256:e56cb99872be7445a4de1d37e5438ca70e3bcd83be7a2b9b385e3538881f8068"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-macos-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543727"
[tools."github:CQLabs/homebrew-dcm"."platforms.windows-x64"]
checksum = "sha256:f133470daa3fb0427f039b424392af7e917d7e7db6b556aa2a968ab0e31587da"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-windows-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543660"
[[tools.java]]
version = "21.0.2"
backend = "core:java"
[tools.java."platforms.linux-arm64"]
checksum = "sha256:08db1392a48d4eb5ea5315cf8f18b89dbaf36cda663ba882cf03c704c9257ec2"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-aarch64_bin.tar.gz"
[tools.java."platforms.linux-x64"]
checksum = "sha256:a2def047a73941e01a73739f92755f86b895811afb1f91243db214cff5bdac3f"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-x64_bin.tar.gz"
[tools.java."platforms.macos-arm64"]
checksum = "sha256:b3d588e16ec1e0ef9805d8a696591bd518a5cea62567da8f53b5ce32d11d22e4"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-aarch64_bin.tar.gz"
[tools.java."platforms.macos-x64"]
checksum = "sha256:8fd09e15dc406387a0aba70bf5d99692874e999bf9cd9208b452b5d76ac922d3"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-x64_bin.tar.gz"
[tools.java."platforms.windows-x64"]
checksum = "sha256:b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip"
+1 -18
View File
@@ -1,19 +1,3 @@
[tools]
"aqua:flutter/flutter" = "3.44.1"
java = "21.0.2"
[tools."github:CQLabs/homebrew-dcm"]
version = "1.37.0"
bin = "dcm"
postinstall = "chmod +x \"$MISE_TOOL_INSTALL_PATH/dcm\" || true"
[tools."github:CQLabs/homebrew-dcm".platforms]
linux-x64 = { asset_pattern = "dcm-linux-x64-release.zip" }
linux-arm64 = { asset_pattern = "dcm-linux-arm-release.zip" }
macos-x64 = { asset_pattern = "dcm-macos-x64-release.zip" }
macos-arm64 = { asset_pattern = "dcm-macos-arm-release.zip" }
windows-x64 = { asset_pattern = "dcm-windows-release.zip" }
[tasks."codegen:dart"]
alias = "codegen"
description = "Execute build_runner to auto-generate dart code"
@@ -45,8 +29,7 @@ run = [
"dart run pigeon --input pigeon/background_worker_lock_api.dart",
"dart run pigeon --input pigeon/connectivity_api.dart",
"dart run pigeon --input pigeon/network_api.dart",
"dart run pigeon --input pigeon/view_intent_api.dart",
"dart format lib/platform/native_sync_api.g.dart lib/platform/local_image_api.g.dart lib/platform/remote_image_api.g.dart lib/platform/background_worker_api.g.dart lib/platform/background_worker_lock_api.g.dart lib/platform/connectivity_api.g.dart lib/platform/network_api.g.dart lib/platform/view_intent_api.g.dart",
"dart format lib/platform/native_sync_api.g.dart lib/platform/local_image_api.g.dart lib/platform/remote_image_api.g.dart lib/platform/background_worker_api.g.dart lib/platform/background_worker_lock_api.g.dart lib/platform/connectivity_api.g.dart lib/platform/network_api.g.dart",
]
[tasks."codegen:translation"]
+1 -1
View File
@@ -1 +1 @@
7.22.0
7.8.0
+1 -1
View File
@@ -4,7 +4,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 3.0.0
- Generator version: 7.22.0
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements
-1
View File
@@ -29,7 +29,6 @@ part 'auth/api_key_auth.dart';
part 'auth/oauth.dart';
part 'auth/http_basic_auth.dart';
part 'auth/http_bearer_auth.dart';
part 'optional.dart';
part 'api/api_keys_api.dart';
part 'api/activities_api.dart';
-3
View File
@@ -226,9 +226,6 @@ Future<String> _decodeBodyBytes(Response response) async {
/// Returns a valid [T] value found at the specified Map [key], null otherwise.
T? mapValueOfType<T>(dynamic map, String key) {
final dynamic value = map is Map ? map[key] : null;
if (T == double && value is int) {
return value.toDouble() as T;
}
return value is T ? value : null;
}
+14 -12
View File
@@ -14,8 +14,8 @@ class ActivityCreateDto {
/// Returns a new [ActivityCreateDto] instance.
ActivityCreateDto({
required this.albumId,
this.assetId = const Optional.absent(),
this.comment = const Optional.absent(),
this.assetId,
this.comment,
required this.type,
});
@@ -29,7 +29,7 @@ class ActivityCreateDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<String?> assetId;
String? assetId;
/// Comment text (required if type is comment)
///
@@ -38,7 +38,7 @@ class ActivityCreateDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<String?> comment;
String? comment;
ReactionType type;
@@ -63,13 +63,15 @@ class ActivityCreateDto {
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'albumId'] = this.albumId;
if (this.assetId.isPresent) {
final value = this.assetId.value;
json[r'assetId'] = value;
if (this.assetId != null) {
json[r'assetId'] = this.assetId;
} else {
// json[r'assetId'] = null;
}
if (this.comment.isPresent) {
final value = this.comment.value;
json[r'comment'] = value;
if (this.comment != null) {
json[r'comment'] = this.comment;
} else {
// json[r'comment'] = null;
}
json[r'type'] = this.type;
return json;
@@ -85,8 +87,8 @@ class ActivityCreateDto {
return ActivityCreateDto(
albumId: mapValueOfType<String>(json, r'albumId')!,
assetId: json.containsKey(r'assetId') ? Optional.present(mapValueOfType<String>(json, r'assetId')) : const Optional.absent(),
comment: json.containsKey(r'comment') ? Optional.present(mapValueOfType<String>(json, r'comment')) : const Optional.absent(),
assetId: mapValueOfType<String>(json, r'assetId'),
comment: mapValueOfType<String>(json, r'comment'),
type: ReactionType.fromJson(json[r'type'])!,
);
}
+8 -7
View File
@@ -14,7 +14,7 @@ class ActivityResponseDto {
/// Returns a new [ActivityResponseDto] instance.
ActivityResponseDto({
required this.assetId,
this.comment = const Optional.absent(),
this.comment,
required this.createdAt,
required this.id,
required this.type,
@@ -25,7 +25,7 @@ class ActivityResponseDto {
String? assetId;
/// Comment text (for comment activities)
Optional<String?> comment;
String? comment;
/// Creation date
DateTime createdAt;
@@ -64,11 +64,12 @@ class ActivityResponseDto {
if (this.assetId != null) {
json[r'assetId'] = this.assetId;
} else {
json[r'assetId'] = null;
// json[r'assetId'] = null;
}
if (this.comment.isPresent) {
final value = this.comment.value;
json[r'comment'] = value;
if (this.comment != null) {
json[r'comment'] = this.comment;
} else {
// json[r'comment'] = null;
}
json[r'createdAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')
? this.createdAt.millisecondsSinceEpoch
@@ -89,7 +90,7 @@ class ActivityResponseDto {
return ActivityResponseDto(
assetId: mapValueOfType<String>(json, r'assetId'),
comment: json.containsKey(r'comment') ? Optional.present(mapValueOfType<String>(json, r'comment')) : const Optional.absent(),
comment: mapValueOfType<String>(json, r'comment'),
createdAt: mapDateTime(json, r'createdAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')!,
id: mapValueOfType<String>(json, r'id')!,
type: ReactionType.fromJson(json[r'type'])!,
+33 -32
View File
@@ -17,17 +17,17 @@ class AlbumResponseDto {
required this.albumThumbnailAssetId,
this.albumUsers = const [],
required this.assetCount,
this.contributorCounts = const Optional.present(const []),
this.contributorCounts = const [],
required this.createdAt,
required this.description,
this.endDate = const Optional.absent(),
this.endDate,
required this.hasSharedLink,
required this.id,
required this.isActivityEnabled,
this.lastModifiedAssetTimestamp = const Optional.absent(),
this.order = const Optional.absent(),
this.lastModifiedAssetTimestamp,
this.order,
required this.shared,
this.startDate = const Optional.absent(),
this.startDate,
required this.updatedAt,
});
@@ -46,7 +46,7 @@ class AlbumResponseDto {
/// Maximum value: 9007199254740991
int assetCount;
Optional<List<ContributorCountResponseDto>?> contributorCounts;
List<ContributorCountResponseDto> contributorCounts;
/// Creation date
DateTime createdAt;
@@ -61,7 +61,7 @@ class AlbumResponseDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<DateTime?> endDate;
DateTime? endDate;
/// Has shared link
bool hasSharedLink;
@@ -79,7 +79,7 @@ class AlbumResponseDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<DateTime?> lastModifiedAssetTimestamp;
DateTime? lastModifiedAssetTimestamp;
///
/// Please note: This property should have been non-nullable! Since the specification file
@@ -87,7 +87,7 @@ class AlbumResponseDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<AssetOrder?> order;
AssetOrder? order;
/// Is shared album
bool shared;
@@ -99,7 +99,7 @@ class AlbumResponseDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<DateTime?> startDate;
DateTime? startDate;
/// Last update date
DateTime updatedAt;
@@ -152,35 +152,36 @@ class AlbumResponseDto {
if (this.albumThumbnailAssetId != null) {
json[r'albumThumbnailAssetId'] = this.albumThumbnailAssetId;
} else {
json[r'albumThumbnailAssetId'] = null;
// json[r'albumThumbnailAssetId'] = null;
}
json[r'albumUsers'] = this.albumUsers;
json[r'assetCount'] = this.assetCount;
if (this.contributorCounts.isPresent) {
final value = this.contributorCounts.value;
json[r'contributorCounts'] = value;
}
json[r'contributorCounts'] = this.contributorCounts;
json[r'createdAt'] = this.createdAt.toUtc().toIso8601String();
json[r'description'] = this.description;
if (this.endDate.isPresent) {
final value = this.endDate.value;
json[r'endDate'] = value == null ? null : value.toUtc().toIso8601String();
if (this.endDate != null) {
json[r'endDate'] = this.endDate!.toUtc().toIso8601String();
} else {
// json[r'endDate'] = null;
}
json[r'hasSharedLink'] = this.hasSharedLink;
json[r'id'] = this.id;
json[r'isActivityEnabled'] = this.isActivityEnabled;
if (this.lastModifiedAssetTimestamp.isPresent) {
final value = this.lastModifiedAssetTimestamp.value;
json[r'lastModifiedAssetTimestamp'] = value == null ? null : value.toUtc().toIso8601String();
if (this.lastModifiedAssetTimestamp != null) {
json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String();
} else {
// json[r'lastModifiedAssetTimestamp'] = null;
}
if (this.order.isPresent) {
final value = this.order.value;
json[r'order'] = value;
if (this.order != null) {
json[r'order'] = this.order;
} else {
// json[r'order'] = null;
}
json[r'shared'] = this.shared;
if (this.startDate.isPresent) {
final value = this.startDate.value;
json[r'startDate'] = value == null ? null : value.toUtc().toIso8601String();
if (this.startDate != null) {
json[r'startDate'] = this.startDate!.toUtc().toIso8601String();
} else {
// json[r'startDate'] = null;
}
json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String();
return json;
@@ -199,17 +200,17 @@ class AlbumResponseDto {
albumThumbnailAssetId: mapValueOfType<String>(json, r'albumThumbnailAssetId'),
albumUsers: AlbumUserResponseDto.listFromJson(json[r'albumUsers']),
assetCount: mapValueOfType<int>(json, r'assetCount')!,
contributorCounts: json.containsKey(r'contributorCounts') ? Optional.present(ContributorCountResponseDto.listFromJson(json[r'contributorCounts'])) : const Optional.absent(),
contributorCounts: ContributorCountResponseDto.listFromJson(json[r'contributorCounts']),
createdAt: mapDateTime(json, r'createdAt', r'')!,
description: mapValueOfType<String>(json, r'description')!,
endDate: json.containsKey(r'endDate') ? Optional.present(mapDateTime(json, r'endDate', r'')) : const Optional.absent(),
endDate: mapDateTime(json, r'endDate', r''),
hasSharedLink: mapValueOfType<bool>(json, r'hasSharedLink')!,
id: mapValueOfType<String>(json, r'id')!,
isActivityEnabled: mapValueOfType<bool>(json, r'isActivityEnabled')!,
lastModifiedAssetTimestamp: json.containsKey(r'lastModifiedAssetTimestamp') ? Optional.present(mapDateTime(json, r'lastModifiedAssetTimestamp', r'')) : const Optional.absent(),
order: json.containsKey(r'order') ? Optional.present(AssetOrder.fromJson(json[r'order'])) : const Optional.absent(),
lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', r''),
order: AssetOrder.fromJson(json[r'order']),
shared: mapValueOfType<bool>(json, r'shared')!,
startDate: json.containsKey(r'startDate') ? Optional.present(mapDateTime(json, r'startDate', r'')) : const Optional.absent(),
startDate: mapDateTime(json, r'startDate', r''),
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
);
}
+7 -6
View File
@@ -13,7 +13,7 @@ part of openapi.api;
class AlbumUserAddDto {
/// Returns a new [AlbumUserAddDto] instance.
AlbumUserAddDto({
this.role = const Optional.absent(),
this.role,
required this.userId,
});
@@ -23,7 +23,7 @@ class AlbumUserAddDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<AlbumUserRole?> role;
AlbumUserRole? role;
/// User ID
String userId;
@@ -44,9 +44,10 @@ class AlbumUserAddDto {
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.role.isPresent) {
final value = this.role.value;
json[r'role'] = value;
if (this.role != null) {
json[r'role'] = this.role;
} else {
// json[r'role'] = null;
}
json[r'userId'] = this.userId;
return json;
@@ -61,7 +62,7 @@ class AlbumUserAddDto {
final json = value.cast<String, dynamic>();
return AlbumUserAddDto(
role: json.containsKey(r'role') ? Optional.present(AlbumUserRole.fromJson(json[r'role'])) : const Optional.absent(),
role: AlbumUserRole.fromJson(json[r'role']),
userId: mapValueOfType<String>(json, r'userId')!,
);
}
@@ -13,7 +13,7 @@ part of openapi.api;
class AlbumsAddAssetsResponseDto {
/// Returns a new [AlbumsAddAssetsResponseDto] instance.
AlbumsAddAssetsResponseDto({
this.error = const Optional.absent(),
this.error,
required this.success,
});
@@ -23,7 +23,7 @@ class AlbumsAddAssetsResponseDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<BulkIdErrorReason?> error;
BulkIdErrorReason? error;
/// Operation success
bool success;
@@ -44,9 +44,10 @@ class AlbumsAddAssetsResponseDto {
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.error.isPresent) {
final value = this.error.value;
json[r'error'] = value;
if (this.error != null) {
json[r'error'] = this.error;
} else {
// json[r'error'] = null;
}
json[r'success'] = this.success;
return json;
@@ -61,7 +62,7 @@ class AlbumsAddAssetsResponseDto {
final json = value.cast<String, dynamic>();
return AlbumsAddAssetsResponseDto(
error: json.containsKey(r'error') ? Optional.present(BulkIdErrorReason.fromJson(json[r'error'])) : const Optional.absent(),
error: BulkIdErrorReason.fromJson(json[r'error']),
success: mapValueOfType<bool>(json, r'success')!,
);
}
+7 -6
View File
@@ -13,7 +13,7 @@ part of openapi.api;
class AlbumsUpdate {
/// Returns a new [AlbumsUpdate] instance.
AlbumsUpdate({
this.defaultAssetOrder = const Optional.absent(),
this.defaultAssetOrder,
});
///
@@ -22,7 +22,7 @@ class AlbumsUpdate {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<AssetOrder?> defaultAssetOrder;
AssetOrder? defaultAssetOrder;
@override
bool operator ==(Object other) => identical(this, other) || other is AlbumsUpdate &&
@@ -38,9 +38,10 @@ class AlbumsUpdate {
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.defaultAssetOrder.isPresent) {
final value = this.defaultAssetOrder.value;
json[r'defaultAssetOrder'] = value;
if (this.defaultAssetOrder != null) {
json[r'defaultAssetOrder'] = this.defaultAssetOrder;
} else {
// json[r'defaultAssetOrder'] = null;
}
return json;
}
@@ -54,7 +55,7 @@ class AlbumsUpdate {
final json = value.cast<String, dynamic>();
return AlbumsUpdate(
defaultAssetOrder: json.containsKey(r'defaultAssetOrder') ? Optional.present(AssetOrder.fromJson(json[r'defaultAssetOrder'])) : const Optional.absent(),
defaultAssetOrder: AssetOrder.fromJson(json[r'defaultAssetOrder']),
);
}
return null;
+7 -6
View File
@@ -13,7 +13,7 @@ part of openapi.api;
class ApiKeyCreateDto {
/// Returns a new [ApiKeyCreateDto] instance.
ApiKeyCreateDto({
this.name = const Optional.absent(),
this.name,
this.permissions = const [],
});
@@ -24,7 +24,7 @@ class ApiKeyCreateDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<String?> name;
String? name;
/// List of permissions
List<Permission> permissions;
@@ -45,9 +45,10 @@ class ApiKeyCreateDto {
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.name.isPresent) {
final value = this.name.value;
json[r'name'] = value;
if (this.name != null) {
json[r'name'] = this.name;
} else {
// json[r'name'] = null;
}
json[r'permissions'] = this.permissions;
return json;
@@ -62,7 +63,7 @@ class ApiKeyCreateDto {
final json = value.cast<String, dynamic>();
return ApiKeyCreateDto(
name: json.containsKey(r'name') ? Optional.present(mapValueOfType<String>(json, r'name')) : const Optional.absent(),
name: mapValueOfType<String>(json, r'name'),
permissions: Permission.listFromJson(json[r'permissions']),
);
}
+11 -13
View File
@@ -13,8 +13,8 @@ part of openapi.api;
class ApiKeyUpdateDto {
/// Returns a new [ApiKeyUpdateDto] instance.
ApiKeyUpdateDto({
this.name = const Optional.absent(),
this.permissions = const Optional.present(const []),
this.name,
this.permissions = const [],
});
/// API key name
@@ -24,10 +24,10 @@ class ApiKeyUpdateDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<String?> name;
String? name;
/// List of permissions
Optional<List<Permission>?> permissions;
List<Permission> permissions;
@override
bool operator ==(Object other) => identical(this, other) || other is ApiKeyUpdateDto &&
@@ -45,14 +45,12 @@ class ApiKeyUpdateDto {
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.name.isPresent) {
final value = this.name.value;
json[r'name'] = value;
}
if (this.permissions.isPresent) {
final value = this.permissions.value;
json[r'permissions'] = value;
if (this.name != null) {
json[r'name'] = this.name;
} else {
// json[r'name'] = null;
}
json[r'permissions'] = this.permissions;
return json;
}
@@ -65,8 +63,8 @@ class ApiKeyUpdateDto {
final json = value.cast<String, dynamic>();
return ApiKeyUpdateDto(
name: json.containsKey(r'name') ? Optional.present(mapValueOfType<String>(json, r'name')) : const Optional.absent(),
permissions: json.containsKey(r'permissions') ? Optional.present(Permission.listFromJson(json[r'permissions'])) : const Optional.absent(),
name: mapValueOfType<String>(json, r'name'),
permissions: Permission.listFromJson(json[r'permissions']),
);
}
return null;
+7 -6
View File
@@ -13,7 +13,7 @@ part of openapi.api;
class AssetBulkDeleteDto {
/// Returns a new [AssetBulkDeleteDto] instance.
AssetBulkDeleteDto({
this.force = const Optional.absent(),
this.force,
this.ids = const [],
});
@@ -24,7 +24,7 @@ class AssetBulkDeleteDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<bool?> force;
bool? force;
/// IDs to process
List<String> ids;
@@ -45,9 +45,10 @@ class AssetBulkDeleteDto {
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.force.isPresent) {
final value = this.force.value;
json[r'force'] = value;
if (this.force != null) {
json[r'force'] = this.force;
} else {
// json[r'force'] = null;
}
json[r'ids'] = this.ids;
return json;
@@ -62,7 +63,7 @@ class AssetBulkDeleteDto {
final json = value.cast<String, dynamic>();
return AssetBulkDeleteDto(
force: json.containsKey(r'force') ? Optional.present(mapValueOfType<bool>(json, r'force')) : const Optional.absent(),
force: mapValueOfType<bool>(json, r'force'),
ids: json[r'ids'] is Iterable
? (json[r'ids'] as Iterable).cast<String>().toList(growable: false)
: const [],
+70 -60
View File
@@ -13,17 +13,17 @@ part of openapi.api;
class AssetBulkUpdateDto {
/// Returns a new [AssetBulkUpdateDto] instance.
AssetBulkUpdateDto({
this.dateTimeOriginal = const Optional.absent(),
this.dateTimeRelative = const Optional.absent(),
this.description = const Optional.absent(),
this.duplicateId = const Optional.absent(),
this.dateTimeOriginal,
this.dateTimeRelative,
this.description,
this.duplicateId,
this.ids = const [],
this.isFavorite = const Optional.absent(),
this.latitude = const Optional.absent(),
this.longitude = const Optional.absent(),
this.rating = const Optional.absent(),
this.timeZone = const Optional.absent(),
this.visibility = const Optional.absent(),
this.isFavorite,
this.latitude,
this.longitude,
this.rating,
this.timeZone,
this.visibility,
});
/// Original date and time
@@ -33,7 +33,7 @@ class AssetBulkUpdateDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<String?> dateTimeOriginal;
String? dateTimeOriginal;
/// Relative time offset in seconds
///
@@ -45,7 +45,7 @@ class AssetBulkUpdateDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<int?> dateTimeRelative;
int? dateTimeRelative;
/// Asset description
///
@@ -54,10 +54,10 @@ class AssetBulkUpdateDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<String?> description;
String? description;
/// Duplicate ID
Optional<String?> duplicateId;
String? duplicateId;
/// Asset IDs to update
List<String> ids;
@@ -69,7 +69,7 @@ class AssetBulkUpdateDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<bool?> isFavorite;
bool? isFavorite;
/// Latitude coordinate
///
@@ -81,7 +81,7 @@ class AssetBulkUpdateDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<num?> latitude;
num? latitude;
/// Longitude coordinate
///
@@ -93,13 +93,13 @@ class AssetBulkUpdateDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<num?> longitude;
num? longitude;
/// Rating in range [1-5], or null for unrated
///
/// Minimum value: -1
/// Maximum value: 5
Optional<int?> rating;
int? rating;
/// Time zone (IANA timezone)
///
@@ -108,7 +108,7 @@ class AssetBulkUpdateDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<String?> timeZone;
String? timeZone;
///
/// Please note: This property should have been non-nullable! Since the specification file
@@ -116,7 +116,7 @@ class AssetBulkUpdateDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<AssetVisibility?> visibility;
AssetVisibility? visibility;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetBulkUpdateDto &&
@@ -152,46 +152,56 @@ class AssetBulkUpdateDto {
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.dateTimeOriginal.isPresent) {
final value = this.dateTimeOriginal.value;
json[r'dateTimeOriginal'] = value;
if (this.dateTimeOriginal != null) {
json[r'dateTimeOriginal'] = this.dateTimeOriginal;
} else {
// json[r'dateTimeOriginal'] = null;
}
if (this.dateTimeRelative.isPresent) {
final value = this.dateTimeRelative.value;
json[r'dateTimeRelative'] = value;
if (this.dateTimeRelative != null) {
json[r'dateTimeRelative'] = this.dateTimeRelative;
} else {
// json[r'dateTimeRelative'] = null;
}
if (this.description.isPresent) {
final value = this.description.value;
json[r'description'] = value;
if (this.description != null) {
json[r'description'] = this.description;
} else {
// json[r'description'] = null;
}
if (this.duplicateId.isPresent) {
final value = this.duplicateId.value;
json[r'duplicateId'] = value;
if (this.duplicateId != null) {
json[r'duplicateId'] = this.duplicateId;
} else {
// json[r'duplicateId'] = null;
}
json[r'ids'] = this.ids;
if (this.isFavorite.isPresent) {
final value = this.isFavorite.value;
json[r'isFavorite'] = value;
if (this.isFavorite != null) {
json[r'isFavorite'] = this.isFavorite;
} else {
// json[r'isFavorite'] = null;
}
if (this.latitude.isPresent) {
final value = this.latitude.value;
json[r'latitude'] = value;
if (this.latitude != null) {
json[r'latitude'] = this.latitude;
} else {
// json[r'latitude'] = null;
}
if (this.longitude.isPresent) {
final value = this.longitude.value;
json[r'longitude'] = value;
if (this.longitude != null) {
json[r'longitude'] = this.longitude;
} else {
// json[r'longitude'] = null;
}
if (this.rating.isPresent) {
final value = this.rating.value;
json[r'rating'] = value;
if (this.rating != null) {
json[r'rating'] = this.rating;
} else {
// json[r'rating'] = null;
}
if (this.timeZone.isPresent) {
final value = this.timeZone.value;
json[r'timeZone'] = value;
if (this.timeZone != null) {
json[r'timeZone'] = this.timeZone;
} else {
// json[r'timeZone'] = null;
}
if (this.visibility.isPresent) {
final value = this.visibility.value;
json[r'visibility'] = value;
if (this.visibility != null) {
json[r'visibility'] = this.visibility;
} else {
// json[r'visibility'] = null;
}
return json;
}
@@ -205,19 +215,19 @@ class AssetBulkUpdateDto {
final json = value.cast<String, dynamic>();
return AssetBulkUpdateDto(
dateTimeOriginal: json.containsKey(r'dateTimeOriginal') ? Optional.present(mapValueOfType<String>(json, r'dateTimeOriginal')) : const Optional.absent(),
dateTimeRelative: json.containsKey(r'dateTimeRelative') ? Optional.present(json[r'dateTimeRelative'] == null ? null : int.parse('${json[r'dateTimeRelative']}')) : const Optional.absent(),
description: json.containsKey(r'description') ? Optional.present(mapValueOfType<String>(json, r'description')) : const Optional.absent(),
duplicateId: json.containsKey(r'duplicateId') ? Optional.present(mapValueOfType<String>(json, r'duplicateId')) : const Optional.absent(),
dateTimeOriginal: mapValueOfType<String>(json, r'dateTimeOriginal'),
dateTimeRelative: mapValueOfType<int>(json, r'dateTimeRelative'),
description: mapValueOfType<String>(json, r'description'),
duplicateId: mapValueOfType<String>(json, r'duplicateId'),
ids: json[r'ids'] is Iterable
? (json[r'ids'] as Iterable).cast<String>().toList(growable: false)
: const [],
isFavorite: json.containsKey(r'isFavorite') ? Optional.present(mapValueOfType<bool>(json, r'isFavorite')) : const Optional.absent(),
latitude: json.containsKey(r'latitude') ? Optional.present(json[r'latitude'] == null ? null : num.parse('${json[r'latitude']}')) : const Optional.absent(),
longitude: json.containsKey(r'longitude') ? Optional.present(json[r'longitude'] == null ? null : num.parse('${json[r'longitude']}')) : const Optional.absent(),
rating: json.containsKey(r'rating') ? Optional.present(json[r'rating'] == null ? null : int.parse('${json[r'rating']}')) : const Optional.absent(),
timeZone: json.containsKey(r'timeZone') ? Optional.present(mapValueOfType<String>(json, r'timeZone')) : const Optional.absent(),
visibility: json.containsKey(r'visibility') ? Optional.present(AssetVisibility.fromJson(json[r'visibility'])) : const Optional.absent(),
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
latitude: num.parse('${json[r'latitude']}'),
longitude: num.parse('${json[r'longitude']}'),
rating: mapValueOfType<int>(json, r'rating'),
timeZone: mapValueOfType<String>(json, r'timeZone'),
visibility: AssetVisibility.fromJson(json[r'visibility']),
);
}
return null;
+21 -18
View File
@@ -14,10 +14,10 @@ class AssetBulkUploadCheckResult {
/// Returns a new [AssetBulkUploadCheckResult] instance.
AssetBulkUploadCheckResult({
required this.action,
this.assetId = const Optional.absent(),
this.assetId,
required this.id,
this.isTrashed = const Optional.absent(),
this.reason = const Optional.absent(),
this.isTrashed,
this.reason,
});
AssetUploadAction action;
@@ -29,7 +29,7 @@ class AssetBulkUploadCheckResult {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<String?> assetId;
String? assetId;
/// Asset ID
String id;
@@ -41,7 +41,7 @@ class AssetBulkUploadCheckResult {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<bool?> isTrashed;
bool? isTrashed;
///
/// Please note: This property should have been non-nullable! Since the specification file
@@ -49,7 +49,7 @@ class AssetBulkUploadCheckResult {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<AssetRejectReason?> reason;
AssetRejectReason? reason;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetBulkUploadCheckResult &&
@@ -74,18 +74,21 @@ class AssetBulkUploadCheckResult {
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'action'] = this.action;
if (this.assetId.isPresent) {
final value = this.assetId.value;
json[r'assetId'] = value;
if (this.assetId != null) {
json[r'assetId'] = this.assetId;
} else {
// json[r'assetId'] = null;
}
json[r'id'] = this.id;
if (this.isTrashed.isPresent) {
final value = this.isTrashed.value;
json[r'isTrashed'] = value;
if (this.isTrashed != null) {
json[r'isTrashed'] = this.isTrashed;
} else {
// json[r'isTrashed'] = null;
}
if (this.reason.isPresent) {
final value = this.reason.value;
json[r'reason'] = value;
if (this.reason != null) {
json[r'reason'] = this.reason;
} else {
// json[r'reason'] = null;
}
return json;
}
@@ -100,10 +103,10 @@ class AssetBulkUploadCheckResult {
return AssetBulkUploadCheckResult(
action: AssetUploadAction.fromJson(json[r'action'])!,
assetId: json.containsKey(r'assetId') ? Optional.present(mapValueOfType<String>(json, r'assetId')) : const Optional.absent(),
assetId: mapValueOfType<String>(json, r'assetId'),
id: mapValueOfType<String>(json, r'id')!,
isTrashed: json.containsKey(r'isTrashed') ? Optional.present(mapValueOfType<bool>(json, r'isTrashed')) : const Optional.absent(),
reason: json.containsKey(r'reason') ? Optional.present(AssetRejectReason.fromJson(json[r'reason'])) : const Optional.absent(),
isTrashed: mapValueOfType<bool>(json, r'isTrashed'),
reason: AssetRejectReason.fromJson(json[r'reason']),
);
}
return null;

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