mirror of
https://github.com/immich-app/immich.git
synced 2026-06-04 13:55:19 -04:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47a0cd3a1f | |||
| de70d19d20 | |||
| 7155bb1e80 | |||
| fa08e72d30 | |||
| e2de8c7c53 | |||
| 429e181c8f | |||
| 7f611d9031 | |||
| e94e22f3f8 | |||
| 4a8c3b60be | |||
| 2190aa72a8 | |||
| d21cb28526 | |||
| 5c33eb3204 | |||
| 137687bc0f | |||
| 9d4a6614b1 | |||
| e4352a7817 | |||
| 156277c629 | |||
| 9e76f09c91 | |||
| 955f491a66 | |||
| c64767034d | |||
| 2382427488 | |||
| d65226e325 | |||
| 86ff373752 | |||
| 6bd001d9ff | |||
| 179e72da7a | |||
| 21506090a5 | |||
| 12c4ee83d6 | |||
| 7956756d38 | |||
| 589e0a7bc5 | |||
| 2424952b9a | |||
| 733100f6ec | |||
| b0f6d5cf38 | |||
| 39d2e14d3a |
@@ -94,6 +94,7 @@ jobs:
|
||||
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||
with:
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
working_directory: ./mobile
|
||||
|
||||
- name: Create the Keystore
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
@@ -219,6 +220,7 @@ jobs:
|
||||
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||
with:
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
working_directory: ./mobile
|
||||
|
||||
- name: Install Flutter dependencies
|
||||
working-directory: ./mobile
|
||||
|
||||
@@ -45,6 +45,7 @@ jobs:
|
||||
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||
with:
|
||||
github_token: ${{ github.token }}
|
||||
working_directory: ./mobile
|
||||
|
||||
- name: Get packages
|
||||
working-directory: ./mobile
|
||||
|
||||
@@ -64,6 +64,7 @@ jobs:
|
||||
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||
with:
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
working_directory: ./mobile
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
@@ -560,6 +560,7 @@ jobs:
|
||||
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||
with:
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
working_directory: ./mobile
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
@@ -112,7 +112,7 @@ services:
|
||||
traefik.enable: true
|
||||
# increase readingTimeouts for the entrypoint used here
|
||||
traefik.http.routers.immich.entrypoints: websecure
|
||||
traefik.http.routers.immich.rule: Host(`immich.your-domain.com`)
|
||||
traefik.http.routers.immich.rule: Host(`immich.example.com`)
|
||||
traefik.http.services.immich.loadbalancer.server.port: 2283
|
||||
```
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ immich-admin list-users
|
||||
[
|
||||
{
|
||||
id: 'e65e6f88-2a30-4dbe-8dd9-1885f4889b53',
|
||||
email: 'immich@example.com.com',
|
||||
email: 'immich@example.com',
|
||||
name: 'Immich Admin',
|
||||
storageLabel: 'admin',
|
||||
externalPath: null,
|
||||
|
||||
@@ -17,7 +17,7 @@ services:
|
||||
ports:
|
||||
- "8888:80"
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: user-name@domain-name.com
|
||||
PGADMIN_DEFAULT_EMAIL: admin@example.com
|
||||
PGADMIN_DEFAULT_PASSWORD: strong-password
|
||||
volumes:
|
||||
- pgadmin-data:/var/lib/pgadmin
|
||||
|
||||
@@ -259,17 +259,6 @@ describe('/search', () => {
|
||||
assets: [assetHeic],
|
||||
}),
|
||||
},
|
||||
{
|
||||
should: "should search city ('')",
|
||||
deferred: () => ({
|
||||
dto: {
|
||||
city: '',
|
||||
visibility: AssetVisibility.Timeline,
|
||||
includeNull: true,
|
||||
},
|
||||
assets: [assetLast],
|
||||
}),
|
||||
},
|
||||
{
|
||||
should: 'should search city (null)',
|
||||
deferred: () => ({
|
||||
@@ -291,18 +280,6 @@ 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: () => ({
|
||||
@@ -324,17 +301,6 @@ describe('/search', () => {
|
||||
assets: [assetFalcon],
|
||||
}),
|
||||
},
|
||||
{
|
||||
should: "should search country ('')",
|
||||
deferred: () => ({
|
||||
dto: {
|
||||
country: '',
|
||||
visibility: AssetVisibility.Timeline,
|
||||
includeNull: true,
|
||||
},
|
||||
assets: [assetLast],
|
||||
}),
|
||||
},
|
||||
{
|
||||
should: 'should search country (null)',
|
||||
deferred: () => ({
|
||||
|
||||
@@ -699,6 +699,7 @@
|
||||
"backup_settings_subtitle": "Manage upload settings",
|
||||
"backup_upload_details_page_more_details": "Tap for more details",
|
||||
"backward": "Backward",
|
||||
"battery_optimization_backup_reliability": "Disabling battery optimizations can improve the reliability of background backup",
|
||||
"biometric_auth_enabled": "Biometric authentication enabled",
|
||||
"biometric_locked_out": "You are locked out of biometric authentication",
|
||||
"biometric_no_options": "No biometric options available",
|
||||
@@ -1687,8 +1688,10 @@
|
||||
"not_available": "N/A",
|
||||
"not_in_any_album": "Not in any album",
|
||||
"not_selected": "Not selected",
|
||||
"not_set": "Not set",
|
||||
"notes": "Notes",
|
||||
"nothing_here_yet": "Nothing here yet",
|
||||
"notification_backup_reliability": "Enable notifications to improve background backup reliability",
|
||||
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
|
||||
"notification_permission_list_tile_content": "Grant permission to enable notifications.",
|
||||
"notification_permission_list_tile_enable_button": "Enable Notifications",
|
||||
|
||||
@@ -816,6 +816,10 @@ 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
|
||||
@@ -860,6 +864,10 @@ 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"
|
||||
@@ -894,6 +902,10 @@ 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))]
|
||||
@@ -996,6 +1008,10 @@ 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")
|
||||
|
||||
|
||||
@@ -1,74 +1,5 @@
|
||||
# @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"
|
||||
@@ -225,30 +156,6 @@ 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"
|
||||
@@ -321,6 +228,34 @@ 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"
|
||||
|
||||
@@ -16,28 +16,14 @@ config_roots = [
|
||||
|
||||
[tools]
|
||||
node = "24.15.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,6 +89,20 @@
|
||||
<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,6 +1,7 @@
|
||||
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
|
||||
@@ -22,6 +23,7 @@ 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
|
||||
|
||||
@@ -31,6 +33,11 @@ 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)
|
||||
@@ -55,6 +62,7 @@ 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)
|
||||
|
||||
+43
-2
@@ -47,18 +47,44 @@ class FlutterError (
|
||||
override val message: String? = null,
|
||||
val details: Any? = null
|
||||
) : RuntimeException()
|
||||
|
||||
enum class PermissionStatus(val raw: Int) {
|
||||
GRANTED(0),
|
||||
DENIED(1),
|
||||
PERMANENTLY_DENIED(2);
|
||||
|
||||
companion object {
|
||||
fun ofRaw(raw: Int): PermissionStatus? {
|
||||
return values().firstOrNull { it.raw == raw }
|
||||
}
|
||||
}
|
||||
}
|
||||
private open class PermissionApiPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return super.readValueOfType(type, buffer)
|
||||
return when (type) {
|
||||
129.toByte() -> {
|
||||
return (readValue(buffer) as Long?)?.let {
|
||||
PermissionStatus.ofRaw(it.toInt())
|
||||
}
|
||||
}
|
||||
else -> super.readValueOfType(type, buffer)
|
||||
}
|
||||
}
|
||||
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||
super.writeValue(stream, value)
|
||||
when (value) {
|
||||
is PermissionStatus -> {
|
||||
stream.write(129)
|
||||
writeValue(stream, value.raw.toLong())
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface PermissionApi {
|
||||
fun isIgnoringBatteryOptimizations(): PermissionStatus
|
||||
fun hasManageMediaPermission(): Boolean
|
||||
fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit)
|
||||
fun manageMediaPermission(callback: (Result<Boolean>) -> Unit)
|
||||
@@ -72,6 +98,21 @@ interface PermissionApi {
|
||||
@JvmOverloads
|
||||
fun setUp(binaryMessenger: BinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.isIgnoringBatteryOptimizations$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
listOf(api.isIgnoringBatteryOptimizations())
|
||||
} catch (exception: Throwable) {
|
||||
PermissionApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
|
||||
+13
@@ -1,13 +1,26 @@
|
||||
package app.alextran.immich.permission
|
||||
|
||||
import android.content.Context
|
||||
import android.os.PowerManager
|
||||
import app.alextran.immich.core.ImmichPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
|
||||
class PermissionApiImpl(context: Context) : ImmichPlugin(), PermissionApi, ActivityAware {
|
||||
private val ctx: Context = context.applicationContext
|
||||
private val manageMediaPermissionDelegate = ManageMediaPermissionDelegate(context)
|
||||
|
||||
private val powerManager =
|
||||
ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
|
||||
|
||||
override fun isIgnoringBatteryOptimizations(): PermissionStatus {
|
||||
if (powerManager.isIgnoringBatteryOptimizations(ctx.packageName)) {
|
||||
return PermissionStatus.GRANTED
|
||||
}
|
||||
return PermissionStatus.DENIED
|
||||
}
|
||||
|
||||
override fun hasManageMediaPermission(): Boolean =
|
||||
manageMediaPermissionDelegate.hasManageMediaPermission()
|
||||
|
||||
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
+3391
File diff suppressed because it is too large
Load Diff
+81
-1
@@ -11,6 +11,24 @@ import Foundation
|
||||
#error("Unsupported platform.")
|
||||
#endif
|
||||
|
||||
/// Error class for passing custom error details to Dart side.
|
||||
final class PigeonError: Error {
|
||||
let code: String
|
||||
let message: String?
|
||||
let details: Sendable?
|
||||
|
||||
init(code: String, message: String?, details: Sendable?) {
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.details = details
|
||||
}
|
||||
|
||||
var localizedDescription: String {
|
||||
return
|
||||
"PigeonError(code: \(code), message: \(message ?? "<nil>"), details: \(details ?? "<nil>")"
|
||||
}
|
||||
}
|
||||
|
||||
private func wrapResult(_ result: Any?) -> [Any?] {
|
||||
return [result]
|
||||
}
|
||||
@@ -46,8 +64,57 @@ private func nilOrValue<T>(_ value: Any?) -> T? {
|
||||
return value as! T?
|
||||
}
|
||||
|
||||
|
||||
enum PermissionStatus: Int {
|
||||
case granted = 0
|
||||
case denied = 1
|
||||
case permanentlyDenied = 2
|
||||
}
|
||||
|
||||
private class PermissionApiPigeonCodecReader: FlutterStandardReader {
|
||||
override func readValue(ofType type: UInt8) -> Any? {
|
||||
switch type {
|
||||
case 129:
|
||||
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
|
||||
if let enumResultAsInt = enumResultAsInt {
|
||||
return PermissionStatus(rawValue: enumResultAsInt)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return super.readValue(ofType: type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class PermissionApiPigeonCodecWriter: FlutterStandardWriter {
|
||||
override func writeValue(_ value: Any) {
|
||||
if let value = value as? PermissionStatus {
|
||||
super.writeByte(129)
|
||||
super.writeValue(value.rawValue)
|
||||
} else {
|
||||
super.writeValue(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class PermissionApiPigeonCodecReaderWriter: FlutterStandardReaderWriter {
|
||||
override func reader(with data: Data) -> FlutterStandardReader {
|
||||
return PermissionApiPigeonCodecReader(data: data)
|
||||
}
|
||||
|
||||
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
|
||||
return PermissionApiPigeonCodecWriter(data: data)
|
||||
}
|
||||
}
|
||||
|
||||
class PermissionApiPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
|
||||
static let shared = PermissionApiPigeonCodec(readerWriter: PermissionApiPigeonCodecReaderWriter())
|
||||
}
|
||||
|
||||
|
||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||
protocol PermissionApi {
|
||||
func isIgnoringBatteryOptimizations() throws -> PermissionStatus
|
||||
func hasManageMediaPermission() throws -> Bool
|
||||
func requestManageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
|
||||
func manageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
|
||||
@@ -55,10 +122,23 @@ protocol PermissionApi {
|
||||
|
||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||
class PermissionApiSetup {
|
||||
static var codec: FlutterStandardMessageCodec { FlutterStandardMessageCodec.sharedInstance() }
|
||||
static var codec: FlutterStandardMessageCodec { PermissionApiPigeonCodec.shared }
|
||||
/// Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`.
|
||||
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
|
||||
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
|
||||
let isIgnoringBatteryOptimizationsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.isIgnoringBatteryOptimizations\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
isIgnoringBatteryOptimizationsChannel.setMessageHandler { _, reply in
|
||||
do {
|
||||
let result = try api.isIgnoringBatteryOptimizations()
|
||||
reply(wrapResult(result))
|
||||
} catch {
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
isIgnoringBatteryOptimizationsChannel.setMessageHandler(nil)
|
||||
}
|
||||
let hasManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
hasManageMediaPermissionChannel.setMessageHandler { _, reply in
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
class PermissionApiImpl: PermissionApi {
|
||||
func isIgnoringBatteryOptimizations() throws -> PermissionStatus {
|
||||
return PermissionStatus.granted;
|
||||
}
|
||||
|
||||
func hasManageMediaPermission() throws -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/settings_key.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
|
||||
const defaultConfig = AppConfig();
|
||||
|
||||
@@ -130,6 +131,8 @@ class AppConfig {
|
||||
.mapIncludeArchived => map.includeArchived,
|
||||
.mapThemeMode => map.themeMode,
|
||||
.mapWithPartners => map.withPartners,
|
||||
.mapCustomFrom => map.customFrom,
|
||||
.mapCustomTo => map.customTo,
|
||||
.cleanupKeepFavorites => cleanup.keepFavorites,
|
||||
.cleanupKeepMediaType => cleanup.keepMediaType,
|
||||
.cleanupKeepAlbumIds => cleanup.keepAlbumIds,
|
||||
@@ -181,6 +184,8 @@ class AppConfig {
|
||||
.mapIncludeArchived => copyWith(map: map.copyWith(includeArchived: value as bool)),
|
||||
.mapThemeMode => copyWith(map: map.copyWith(themeMode: value as ThemeMode)),
|
||||
.mapWithPartners => copyWith(map: map.copyWith(withPartners: value as bool)),
|
||||
.mapCustomFrom => copyWith(map: map.copyWith(customFrom: value as Option<DateTime>)),
|
||||
.mapCustomTo => copyWith(map: map.copyWith(customTo: value as Option<DateTime>)),
|
||||
.cleanupKeepFavorites => copyWith(cleanup: cleanup.copyWith(keepFavorites: value as bool)),
|
||||
.cleanupKeepMediaType => copyWith(cleanup: cleanup.copyWith(keepMediaType: value as AssetKeepType)),
|
||||
.cleanupKeepAlbumIds => copyWith(cleanup: cleanup.copyWith(keepAlbumIds: value as List<String>)),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
|
||||
class MapConfig {
|
||||
final int relativeDays;
|
||||
@@ -6,6 +7,8 @@ class MapConfig {
|
||||
final bool includeArchived;
|
||||
final ThemeMode themeMode;
|
||||
final bool withPartners;
|
||||
final Option<DateTime> customFrom;
|
||||
final Option<DateTime> customTo;
|
||||
|
||||
const MapConfig({
|
||||
this.relativeDays = 0,
|
||||
@@ -13,6 +16,8 @@ class MapConfig {
|
||||
this.includeArchived = false,
|
||||
this.themeMode = ThemeMode.system,
|
||||
this.withPartners = false,
|
||||
this.customFrom = const Option.none(),
|
||||
this.customTo = const Option.none(),
|
||||
});
|
||||
|
||||
MapConfig copyWith({
|
||||
@@ -21,12 +26,16 @@ class MapConfig {
|
||||
bool? includeArchived,
|
||||
ThemeMode? themeMode,
|
||||
bool? withPartners,
|
||||
Option<DateTime>? customFrom,
|
||||
Option<DateTime>? customTo,
|
||||
}) => MapConfig(
|
||||
relativeDays: relativeDays ?? this.relativeDays,
|
||||
favoritesOnly: favoritesOnly ?? this.favoritesOnly,
|
||||
includeArchived: includeArchived ?? this.includeArchived,
|
||||
themeMode: themeMode ?? this.themeMode,
|
||||
withPartners: withPartners ?? this.withPartners,
|
||||
customFrom: customFrom ?? this.customFrom,
|
||||
customTo: customTo ?? this.customTo,
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -37,12 +46,15 @@ class MapConfig {
|
||||
other.favoritesOnly == favoritesOnly &&
|
||||
other.includeArchived == includeArchived &&
|
||||
other.themeMode == themeMode &&
|
||||
other.withPartners == withPartners);
|
||||
other.withPartners == withPartners &&
|
||||
other.customFrom == customFrom &&
|
||||
other.customTo == customTo);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(relativeDays, favoritesOnly, includeArchived, themeMode, withPartners);
|
||||
int get hashCode =>
|
||||
Object.hash(relativeDays, favoritesOnly, includeArchived, themeMode, withPartners, customFrom, customTo);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'MapConfig(relativeDays: $relativeDays, favoritesOnly: $favoritesOnly, includeArchived: $includeArchived, themeMode: $themeMode, withPartners: $withPartners)';
|
||||
'MapConfig(relativeDays: $relativeDays, favoritesOnly: $favoritesOnly, includeArchived: $includeArchived, themeMode: $themeMode, withPartners: $withPartners, customFrom: $customFrom, customTo: $customTo)';
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
|
||||
enum SettingsKey<T extends Object> {
|
||||
// Theme
|
||||
@@ -58,6 +59,8 @@ enum SettingsKey<T extends Object> {
|
||||
mapIncludeArchived<bool>(),
|
||||
mapThemeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
|
||||
mapWithPartners<bool>(),
|
||||
mapCustomFrom<Option<DateTime>>(codec: _OptionCodec(_DateTimeCodec())),
|
||||
mapCustomTo<Option<DateTime>>(codec: _OptionCodec(_DateTimeCodec())),
|
||||
|
||||
// Cleanup
|
||||
cleanupKeepFavorites<bool>(),
|
||||
@@ -129,6 +132,30 @@ final class _DateTimeCodec extends _SettingsCodec<DateTime> {
|
||||
DateTime decode(String raw) => DateTime.parse(raw);
|
||||
}
|
||||
|
||||
final class _OptionCodec<T extends Object> extends _SettingsCodec<Option<T>> {
|
||||
final _SettingsCodec<T> _inner;
|
||||
|
||||
const _OptionCodec(this._inner);
|
||||
|
||||
@override
|
||||
String encode(Option<T> value) => switch (value) {
|
||||
Some(:final value) => _inner.encode(value),
|
||||
None() => '',
|
||||
};
|
||||
|
||||
@override
|
||||
Option<T> decode(String raw) {
|
||||
if (raw.isEmpty) {
|
||||
return const Option.none();
|
||||
}
|
||||
try {
|
||||
return Option.some(_inner.decode(raw));
|
||||
} on FormatException {
|
||||
return const Option.none();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class _MapCodec<K extends Object, V extends Object> extends _SettingsCodec<Map<K, V>> {
|
||||
final _SettingsCodec<K> _keyCodec;
|
||||
final _SettingsCodec<V> _valueCodec;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
|
||||
class TimeRange {
|
||||
final Option<DateTime> from;
|
||||
final Option<DateTime> to;
|
||||
|
||||
const TimeRange({this.from = const None(), this.to = const None()});
|
||||
|
||||
TimeRange copyWith({Option<DateTime>? from, Option<DateTime>? to}) {
|
||||
return TimeRange(from: from ?? this.from, to: to ?? this.to);
|
||||
}
|
||||
|
||||
TimeRange clearFrom() => TimeRange(to: to);
|
||||
TimeRange clearTo() => TimeRange(from: from);
|
||||
}
|
||||
@@ -55,7 +55,6 @@ class Preferences {
|
||||
final bool tagsEnabled;
|
||||
final AvatarColor userAvatarColor;
|
||||
final bool showSupportBadge;
|
||||
final int minimumFaces;
|
||||
|
||||
const Preferences({
|
||||
this.foldersEnabled = false,
|
||||
@@ -66,7 +65,6 @@ class Preferences {
|
||||
this.tagsEnabled = false,
|
||||
this.userAvatarColor = AvatarColor.primary,
|
||||
this.showSupportBadge = true,
|
||||
this.minimumFaces = 3,
|
||||
});
|
||||
|
||||
Preferences copyWith({
|
||||
@@ -78,7 +76,6 @@ class Preferences {
|
||||
bool? tagsEnabled,
|
||||
AvatarColor? userAvatarColor,
|
||||
bool? showSupportBadge,
|
||||
int? minimumFaces,
|
||||
}) {
|
||||
return Preferences(
|
||||
foldersEnabled: foldersEnabled ?? this.foldersEnabled,
|
||||
@@ -89,7 +86,6 @@ class Preferences {
|
||||
tagsEnabled: tagsEnabled ?? this.tagsEnabled,
|
||||
userAvatarColor: userAvatarColor ?? this.userAvatarColor,
|
||||
showSupportBadge: showSupportBadge ?? this.showSupportBadge,
|
||||
minimumFaces: minimumFaces ?? this.minimumFaces,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,7 +99,6 @@ class Preferences {
|
||||
preferences["tags-Enabled"] = tagsEnabled;
|
||||
preferences["avatar-Color"] = userAvatarColor.value;
|
||||
preferences["purchase-ShowSupportBadge"] = showSupportBadge;
|
||||
preferences["minimumFaces"] = minimumFaces;
|
||||
return preferences;
|
||||
}
|
||||
|
||||
@@ -120,7 +115,6 @@ class Preferences {
|
||||
orElse: () => AvatarColor.primary,
|
||||
),
|
||||
showSupportBadge: (map["purchase"] as Map<String, Object?>?)?["showSupportBadge"] as bool? ?? true,
|
||||
minimumFaces: (map["people"] as Map<String, Object?>?)?["minimumFaces"] as int? ?? 3,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -135,7 +129,6 @@ sharedLinksEnabled: $sharedLinksEnabled,
|
||||
tagsEnabled: $tagsEnabled,
|
||||
userAvatarColor: $userAvatarColor,
|
||||
showSupportBadge: $showSupportBadge,
|
||||
minimumFaces: $minimumFaces,
|
||||
}''';
|
||||
}
|
||||
|
||||
@@ -152,8 +145,7 @@ minimumFaces: $minimumFaces,
|
||||
other.sharedLinksEnabled == sharedLinksEnabled &&
|
||||
other.tagsEnabled == tagsEnabled &&
|
||||
other.userAvatarColor == userAvatarColor &&
|
||||
other.showSupportBadge == showSupportBadge &&
|
||||
other.minimumFaces == minimumFaces;
|
||||
other.showSupportBadge == showSupportBadge;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -165,8 +157,7 @@ minimumFaces: $minimumFaces,
|
||||
sharedLinksEnabled.hashCode ^
|
||||
tagsEnabled.hashCode ^
|
||||
userAvatarColor.hashCode ^
|
||||
showSupportBadge.hashCode ^
|
||||
minimumFaces.hashCode;
|
||||
showSupportBadge.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -113,9 +113,35 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
|
||||
@override
|
||||
Future<void> onIosUpload(bool isRefresh, int? maxSeconds) async {
|
||||
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');
|
||||
_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();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _backgroundLoop({
|
||||
|
||||
@@ -18,8 +18,8 @@ class DriftPeopleService {
|
||||
return _repository.getAssetPeople(assetId);
|
||||
}
|
||||
|
||||
Future<List<DriftPerson>> getAllPeople({int minFaces = 3}) {
|
||||
return _repository.getAllPeople(minFaces: minFaces);
|
||||
Future<List<DriftPerson>> getAllPeople() {
|
||||
return _repository.getAllPeople();
|
||||
}
|
||||
|
||||
Future<int> updateName(String personId, String name) async {
|
||||
|
||||
@@ -8,6 +8,7 @@ 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';
|
||||
@@ -137,7 +138,7 @@ class RemoteAlbumService {
|
||||
Future<RemoteAlbum> updateAlbum(
|
||||
String albumId, {
|
||||
String? name,
|
||||
String? description,
|
||||
Optional<String?> description = const Optional.absent(),
|
||||
String? thumbnailAssetId,
|
||||
bool? isActivityEnabled,
|
||||
AlbumAssetOrder? order,
|
||||
|
||||
@@ -6,6 +6,7 @@ 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,3 +1348,7 @@ 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 => 27;
|
||||
int get schemaVersion => 28;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -279,6 +279,9 @@ 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,6 +112,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
i7.idxLocalAlbumAssetAlbumAsset,
|
||||
i4.idxLocalAssetChecksum,
|
||||
i4.idxLocalAssetCloudId,
|
||||
i4.idxLocalAssetCreatedAt,
|
||||
i3.idxStackPrimaryAssetId,
|
||||
i2.uQRemoteAssetsOwnerChecksum,
|
||||
i2.uQRemoteAssetsOwnerLibraryChecksum,
|
||||
|
||||
@@ -14083,6 +14083,554 @@ 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,
|
||||
@@ -14110,6 +14658,7 @@ 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) {
|
||||
@@ -14243,6 +14792,11 @@ 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');
|
||||
}
|
||||
@@ -14276,6 +14830,7 @@ 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,
|
||||
@@ -14304,5 +14859,6 @@ 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.asc(_db.localAssetEntity.id)]);
|
||||
..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)]);
|
||||
|
||||
return query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get();
|
||||
}
|
||||
|
||||
@@ -27,7 +27,18 @@ class DriftMapRepository extends DriftDatabaseRepository {
|
||||
condition = condition & _db.remoteAssetEntity.isFavorite.equals(true);
|
||||
}
|
||||
|
||||
if (options.relativeDays != 0) {
|
||||
final timeRange = options.timeRange;
|
||||
final hasCustomRange = timeRange.from.isSome || timeRange.to.isSome;
|
||||
|
||||
if (hasCustomRange) {
|
||||
timeRange.from.ifPresent((from) {
|
||||
condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(from);
|
||||
});
|
||||
|
||||
timeRange.to.ifPresent((to) {
|
||||
condition = condition & _db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(to);
|
||||
});
|
||||
} else if (options.relativeDays > 0) {
|
||||
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
|
||||
condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
|
||||
}).get();
|
||||
}
|
||||
|
||||
Future<List<DriftPerson>> getAllPeople({int minFaces = 3}) async {
|
||||
Future<List<DriftPerson>> getAllPeople() async {
|
||||
final people = _db.personEntity;
|
||||
final faces = _db.assetFaceEntity;
|
||||
final assets = _db.remoteAssetEntity;
|
||||
@@ -49,7 +49,7 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
|
||||
faces.isVisible.equals(true) &
|
||||
faces.deletedAt.isNull(),
|
||||
)
|
||||
..groupBy([people.id], having: faces.id.count().isBiggerOrEqualValue(minFaces) | people.name.equals('').not())
|
||||
..groupBy([people.id], having: faces.id.count().isBiggerOrEqualValue(3) | people.name.equals('').not())
|
||||
..orderBy([
|
||||
OrderingTerm(expression: people.name.equals('').not(), mode: OrderingMode.desc),
|
||||
OrderingTerm(expression: faces.id.count(), mode: OrderingMode.desc),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/time_range.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
|
||||
@@ -21,6 +22,7 @@ class TimelineMapOptions {
|
||||
final bool includeArchived;
|
||||
final bool withPartners;
|
||||
final int relativeDays;
|
||||
final TimeRange timeRange;
|
||||
|
||||
const TimelineMapOptions({
|
||||
required this.bounds,
|
||||
@@ -28,6 +30,7 @@ class TimelineMapOptions {
|
||||
this.includeArchived = false,
|
||||
this.withPartners = false,
|
||||
this.relativeDays = 0,
|
||||
this.timeRange = const TimeRange(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -553,8 +556,21 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
query.where(_db.remoteAssetEntity.isFavorite.equals(true));
|
||||
}
|
||||
|
||||
if (options.relativeDays != 0) {
|
||||
final timeRange = options.timeRange;
|
||||
|
||||
final hasCustomRange = timeRange.from.isSome || timeRange.to.isSome;
|
||||
|
||||
if (hasCustomRange) {
|
||||
timeRange.from.ifPresent((from) {
|
||||
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(from));
|
||||
});
|
||||
|
||||
timeRange.to.ifPresent((to) {
|
||||
query.where(_db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(to));
|
||||
});
|
||||
} else if (options.relativeDays > 0) {
|
||||
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
|
||||
|
||||
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
|
||||
}
|
||||
|
||||
@@ -595,8 +611,21 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
query.where(_db.remoteAssetEntity.isFavorite.equals(true));
|
||||
}
|
||||
|
||||
if (options.relativeDays != 0) {
|
||||
final timeRange = options.timeRange;
|
||||
|
||||
final hasCustomRange = timeRange.from.isSome || timeRange.to.isSome;
|
||||
|
||||
if (hasCustomRange) {
|
||||
timeRange.from.ifPresent((from) {
|
||||
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(from));
|
||||
});
|
||||
|
||||
timeRange.to.ifPresent((to) {
|
||||
query.where(_db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(to));
|
||||
});
|
||||
} else if (options.relativeDays > 0) {
|
||||
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
|
||||
|
||||
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ 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';
|
||||
@@ -128,6 +129,7 @@ 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");
|
||||
@@ -233,6 +235,7 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
|
||||
}
|
||||
});
|
||||
|
||||
ref.read(viewIntentHandlerProvider).init();
|
||||
ref.read(shareIntentUploadProvider.notifier).init();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
@@ -15,11 +16,16 @@ import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.w
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/sync_status.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
@RoutePage()
|
||||
@@ -162,11 +168,7 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||
),
|
||||
),
|
||||
},
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.info_outline_rounded),
|
||||
onPressed: () => context.pushRoute(const DriftUploadDetailRoute()),
|
||||
label: Text("view_details".t(context: context)),
|
||||
),
|
||||
const _BackupFooter(),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -177,6 +179,137 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||
}
|
||||
}
|
||||
|
||||
class _BackupFooter extends ConsumerStatefulWidget {
|
||||
const _BackupFooter();
|
||||
|
||||
@override
|
||||
ConsumerState<_BackupFooter> createState() => _BackupFooterState();
|
||||
}
|
||||
|
||||
class _BackupFooterState extends ConsumerState<_BackupFooter> with WidgetsBindingObserver {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (CurrentPlatform.isAndroid && state == AppLifecycleState.resumed && mounted) {
|
||||
unawaited(ref.read(notificationPermissionProvider.notifier).getNotificationPermission());
|
||||
unawaited(ref.read(batteryOptimizationProvider.notifier).getBatteryOptimizationPermission());
|
||||
}
|
||||
}
|
||||
|
||||
void showPermissionsDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
content: Text(context.t.notification_permission_dialog_content),
|
||||
actions: [
|
||||
ImmichTextButton(
|
||||
labelText: context.t.cancel,
|
||||
variant: .ghost,
|
||||
expanded: false,
|
||||
onPressed: () => ContextHelper(ctx).pop(),
|
||||
),
|
||||
ImmichTextButton(
|
||||
labelText: context.t.settings,
|
||||
variant: .ghost,
|
||||
expanded: false,
|
||||
onPressed: () {
|
||||
ContextHelper(context).pop();
|
||||
openAppSettings();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void showBatteryOptimizationInfo() {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
title: Text(context.t.backup_controller_page_background_battery_info_title),
|
||||
content: SingleChildScrollView(child: Text(context.t.backup_controller_page_background_battery_info_message)),
|
||||
actions: [
|
||||
ImmichTextButton(
|
||||
labelText: context.t.backup_controller_page_background_battery_info_link,
|
||||
variant: .ghost,
|
||||
expanded: false,
|
||||
onPressed: () => launchUrl(Uri.parse('https://dontkillmyapp.com'), mode: LaunchMode.externalApplication),
|
||||
),
|
||||
ImmichTextButton(
|
||||
labelText: context.t.backup_controller_page_background_battery_info_ok,
|
||||
variant: .ghost,
|
||||
expanded: false,
|
||||
onPressed: () => ContextHelper(ctx).pop(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isBackupEnabled = ref.watch(appConfigProvider.select((config) => config.backup.enabled));
|
||||
final notificationStatus = ref.watch(notificationPermissionProvider);
|
||||
final batteryOptimizationStatus = ref.watch(batteryOptimizationProvider).valueOrNull;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (CurrentPlatform.isAndroid && isBackupEnabled) ...[
|
||||
if (notificationStatus != PermissionStatus.granted)
|
||||
TextButton.icon(
|
||||
iconAlignment: .end,
|
||||
icon: Icon(Icons.open_in_new_outlined, color: context.colorScheme.onSurfaceSecondary),
|
||||
label: Text(
|
||||
context.t.notification_backup_reliability,
|
||||
textAlign: TextAlign.left,
|
||||
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
onPressed: () {
|
||||
ref.read(notificationPermissionProvider.notifier).requestNotificationPermission().then((p) {
|
||||
if (p == PermissionStatus.permanentlyDenied) {
|
||||
showPermissionsDialog();
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
if (notificationStatus != PermissionStatus.granted && batteryOptimizationStatus != PermissionStatus.granted)
|
||||
const Divider(indent: 32, endIndent: 32),
|
||||
if (batteryOptimizationStatus != PermissionStatus.granted)
|
||||
TextButton.icon(
|
||||
iconAlignment: .end,
|
||||
icon: Icon(Icons.open_in_new_outlined, color: context.colorScheme.onSurfaceSecondary),
|
||||
label: Text(
|
||||
context.t.battery_optimization_backup_reliability,
|
||||
textAlign: TextAlign.left,
|
||||
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
onPressed: showBatteryOptimizationInfo,
|
||||
),
|
||||
],
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.info_outline_rounded),
|
||||
onPressed: () => context.pushRoute(const DriftUploadDetailRoute()),
|
||||
label: Text(context.t.view_details),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BackupAlbumSelectionCard extends ConsumerWidget {
|
||||
const _BackupAlbumSelectionCard();
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ 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';
|
||||
@@ -314,6 +315,7 @@ 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(
|
||||
@@ -328,6 +330,8 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
backgroundManager.syncRemote().then((success) => syncSuccess = success),
|
||||
]);
|
||||
|
||||
await viewIntentHandler.flushDeferredViewIntent();
|
||||
|
||||
if (syncSuccess) {
|
||||
await Future.wait([
|
||||
backgroundManager.hashAssets().then((_) {
|
||||
|
||||
@@ -11,6 +11,7 @@ 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';
|
||||
@@ -365,11 +366,10 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
bool? download;
|
||||
bool? upload;
|
||||
bool? meta;
|
||||
String? desc;
|
||||
String? password;
|
||||
var password = const Optional<String?>.absent();
|
||||
var description = const Optional<String?>.absent();
|
||||
String? slug;
|
||||
DateTime? expiry;
|
||||
bool? changeExpiry;
|
||||
var expiry = const Optional<DateTime?>.absent();
|
||||
|
||||
if (allowDownload.value != existingLink!.allowDownload) {
|
||||
download = allowDownload.value;
|
||||
@@ -383,12 +383,16 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
meta = showMetadata.value;
|
||||
}
|
||||
|
||||
if (descriptionController.text != existingLink!.description) {
|
||||
desc = descriptionController.text;
|
||||
if (descriptionController.text != (existingLink!.description ?? '')) {
|
||||
description = descriptionController.text.isEmpty
|
||||
? const Optional.present(null)
|
||||
: Optional.present(descriptionController.text);
|
||||
}
|
||||
|
||||
if (passwordController.text != existingLink!.password) {
|
||||
password = passwordController.text;
|
||||
if (passwordController.text != (existingLink!.password ?? '')) {
|
||||
password = passwordController.text.isEmpty
|
||||
? const Optional.present(null)
|
||||
: Optional.present(passwordController.text);
|
||||
}
|
||||
|
||||
if (slugController.text != (existingLink!.slug ?? "")) {
|
||||
@@ -399,8 +403,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
|
||||
final newExpiry = expiryAfter.value;
|
||||
if (newExpiry?.toUtc() != existingLink!.expiresAt?.toUtc()) {
|
||||
expiry = newExpiry;
|
||||
changeExpiry = true;
|
||||
expiry = newExpiry == null ? const Optional.present(null) : Optional.present(newExpiry.toUtc());
|
||||
}
|
||||
|
||||
await ref
|
||||
@@ -410,11 +413,10 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
showMeta: meta,
|
||||
allowDownload: download,
|
||||
allowUpload: upload,
|
||||
description: desc,
|
||||
description: description,
|
||||
password: password,
|
||||
slug: slug,
|
||||
expiresAt: expiry?.toUtc(),
|
||||
changeExpiry: changeExpiry,
|
||||
expiresAt: expiry,
|
||||
);
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
|
||||
+27
@@ -26,6 +26,8 @@ Object? _extractReplyValueOrThrow(List<Object?>? replyList, String channelName,
|
||||
return replyList.firstOrNull;
|
||||
}
|
||||
|
||||
enum PermissionStatus { granted, denied, permanentlyDenied }
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
@@ -33,6 +35,9 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||
if (value is int) {
|
||||
buffer.putUint8(4);
|
||||
buffer.putInt64(value);
|
||||
} else if (value is PermissionStatus) {
|
||||
buffer.putUint8(129);
|
||||
writeValue(buffer, value.index);
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
}
|
||||
@@ -41,6 +46,9 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||
@override
|
||||
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||
switch (type) {
|
||||
case 129:
|
||||
final value = readValue(buffer) as int?;
|
||||
return value == null ? null : PermissionStatus.values[value];
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
}
|
||||
@@ -60,6 +68,25 @@ class PermissionApi {
|
||||
|
||||
final String pigeonVar_messageChannelSuffix;
|
||||
|
||||
Future<PermissionStatus> isIgnoringBatteryOptimizations() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.PermissionApi.isIgnoringBatteryOptimizations$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: false,
|
||||
);
|
||||
return pigeonVar_replyValue! as PermissionStatus;
|
||||
}
|
||||
|
||||
Future<bool> hasManageMediaPermission() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission$pigeonVar_messageChannelSuffix';
|
||||
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
// 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,6 +20,7 @@ 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 {
|
||||
@@ -247,10 +248,13 @@ 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: newDescription);
|
||||
.updateAlbum(widget.album.id, name: newTitle, description: description);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
@@ -10,6 +11,9 @@ 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';
|
||||
|
||||
@@ -26,7 +30,11 @@ 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();
|
||||
@@ -35,22 +43,50 @@ class UploadActionButton extends ConsumerWidget {
|
||||
}
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
} else {
|
||||
unawaited(
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) => const _UploadProgressDialog(),
|
||||
),
|
||||
);
|
||||
isUploadDialogOpen = true;
|
||||
uploadDialogFuture =
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) => _UploadProgressDialog(
|
||||
onCancel: () {
|
||||
wasUploadCancelled = true;
|
||||
},
|
||||
),
|
||||
).whenComplete(() {
|
||||
isUploadDialogOpen = false;
|
||||
});
|
||||
unawaited(uploadDialogFuture);
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).upload(source, assets: assets);
|
||||
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;
|
||||
}
|
||||
|
||||
if (!isTimeline && context.mounted) {
|
||||
if (!isTimeline && context.mounted && isUploadDialogOpen) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
|
||||
if (context.mounted && !result.success) {
|
||||
if (context.mounted && !success && !wasUploadCancelled) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'scaffold_body_error_occurred'.t(context: context),
|
||||
@@ -73,7 +109,9 @@ class UploadActionButton extends ConsumerWidget {
|
||||
}
|
||||
|
||||
class _UploadProgressDialog extends ConsumerWidget {
|
||||
const _UploadProgressDialog();
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const _UploadProgressDialog({required this.onCancel});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -103,7 +141,8 @@ class _UploadProgressDialog extends ConsumerWidget {
|
||||
onPressed: () {
|
||||
ref.read(manualUploadCancelTokenProvider)?.complete();
|
||||
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
|
||||
Navigator.of(context).pop();
|
||||
onCancel();
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
},
|
||||
labelText: 'cancel'.t(context: context),
|
||||
),
|
||||
|
||||
@@ -21,6 +21,7 @@ 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';
|
||||
|
||||
@@ -323,14 +324,16 @@ 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: getFullImageProvider(asset, size: size),
|
||||
imageProvider: imageProvider,
|
||||
heroAttributes: heroAttributes,
|
||||
loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()),
|
||||
gaplessPlayback: true,
|
||||
@@ -377,12 +380,9 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
child: NativeVideoViewer(
|
||||
key: _NativeVideoViewerKey(asset.heroTag),
|
||||
asset: asset,
|
||||
localFilePath: localFilePath,
|
||||
isCurrent: isCurrent,
|
||||
image: Image(
|
||||
image: getFullImageProvider(asset, size: size),
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -393,6 +393,7 @@ 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) {
|
||||
@@ -421,6 +422,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
_scrollController.snapPosition.snapOffset = _snapOffset;
|
||||
}
|
||||
|
||||
final viewIntentFilePath = timelineOrigin == TimelineOrigin.deepLink ? ref.watch(viewIntentFilePathProvider) : null;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
@@ -440,6 +443,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
: null,
|
||||
isCurrent: isCurrent,
|
||||
isPlayingMotionVideo: isPlayingMotionVideo,
|
||||
localFilePath: viewIntentFilePath,
|
||||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -19,6 +20,7 @@ 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;
|
||||
@@ -26,6 +28,7 @@ class NativeVideoViewer extends ConsumerStatefulWidget {
|
||||
const NativeVideoViewer({
|
||||
super.key,
|
||||
required this.asset,
|
||||
this.localFilePath,
|
||||
required this.image,
|
||||
this.isCurrent = false,
|
||||
this.showControls = true,
|
||||
@@ -106,6 +109,19 @@ 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,3 +1,4 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:async/async.dart';
|
||||
@@ -146,10 +147,17 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
|
||||
}
|
||||
}
|
||||
|
||||
ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920), bool edited = true}) {
|
||||
ImageProvider getFullImageProvider(
|
||||
BaseAsset asset, {
|
||||
Size size = const Size(1080, 1920),
|
||||
bool edited = true,
|
||||
String? localFilePath,
|
||||
}) {
|
||||
// Create new provider and cache it
|
||||
final ImageProvider provider;
|
||||
if (_shouldUseLocalAsset(asset)) {
|
||||
if (localFilePath != null) {
|
||||
provider = FileImage(File(localFilePath));
|
||||
} else 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,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/models/time_range.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/map.provider.dart';
|
||||
@@ -15,6 +16,7 @@ class MapState {
|
||||
final bool includeArchived;
|
||||
final bool withPartners;
|
||||
final int relativeDays;
|
||||
final TimeRange timeRange;
|
||||
|
||||
const MapState({
|
||||
this.themeMode = ThemeMode.system,
|
||||
@@ -23,6 +25,7 @@ class MapState {
|
||||
this.includeArchived = false,
|
||||
this.withPartners = false,
|
||||
this.relativeDays = 0,
|
||||
this.timeRange = const TimeRange(),
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -40,6 +43,7 @@ class MapState {
|
||||
bool? includeArchived,
|
||||
bool? withPartners,
|
||||
int? relativeDays,
|
||||
TimeRange? timeRange,
|
||||
}) {
|
||||
return MapState(
|
||||
bounds: bounds ?? this.bounds,
|
||||
@@ -48,6 +52,7 @@ class MapState {
|
||||
includeArchived: includeArchived ?? this.includeArchived,
|
||||
withPartners: withPartners ?? this.withPartners,
|
||||
relativeDays: relativeDays ?? this.relativeDays,
|
||||
timeRange: timeRange ?? this.timeRange,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,6 +62,7 @@ class MapState {
|
||||
includeArchived: includeArchived,
|
||||
withPartners: withPartners,
|
||||
relativeDays: relativeDays,
|
||||
timeRange: timeRange,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,6 +109,13 @@ class MapStateNotifier extends Notifier<MapState> {
|
||||
EventStream.shared.emit(const MapMarkerReloadEvent());
|
||||
}
|
||||
|
||||
void setTimeRange(TimeRange range) {
|
||||
ref.read(settingsProvider).write(.mapCustomFrom, range.from);
|
||||
ref.read(settingsProvider).write(.mapCustomTo, range.to);
|
||||
state = state.copyWith(timeRange: range);
|
||||
EventStream.shared.emit(const MapMarkerReloadEvent());
|
||||
}
|
||||
|
||||
@override
|
||||
MapState build() {
|
||||
final mapConfig = ref.read(appConfigProvider.select((config) => config.map));
|
||||
@@ -111,8 +124,9 @@ class MapStateNotifier extends Notifier<MapState> {
|
||||
onlyFavorites: mapConfig.favoritesOnly,
|
||||
includeArchived: mapConfig.includeArchived,
|
||||
withPartners: mapConfig.withPartners,
|
||||
relativeDays: mapConfig.relativeDays,
|
||||
bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)),
|
||||
relativeDays: mapConfig.relativeDays,
|
||||
timeRange: TimeRange(from: mapConfig.customFrom, to: mapConfig.customTo),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,39 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/time_range.model.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_settings/map_custom_time_range.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_settings/map_settings_list_tile.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_settings/map_settings_time_dropdown.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_settings/map_theme_picker.dart';
|
||||
|
||||
class DriftMapSettingsSheet extends HookConsumerWidget {
|
||||
class DriftMapSettingsSheet extends ConsumerStatefulWidget {
|
||||
const DriftMapSettingsSheet({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<DriftMapSettingsSheet> createState() => _DriftMapSettingsSheetState();
|
||||
}
|
||||
|
||||
class _DriftMapSettingsSheetState extends ConsumerState<DriftMapSettingsSheet> {
|
||||
late bool useCustomRange;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final mapState = ref.read(mapStateProvider);
|
||||
final timeRange = mapState.timeRange;
|
||||
useCustomRange = timeRange.from.isSome || timeRange.to.isSome;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mapState = ref.watch(mapStateProvider);
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: 0.6,
|
||||
initialChildSize: useCustomRange ? 0.7 : 0.6,
|
||||
builder: (ctx, scrollController) => SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
child: Card(
|
||||
@@ -47,10 +65,41 @@ class DriftMapSettingsSheet extends HookConsumerWidget {
|
||||
selected: mapState.withPartners,
|
||||
onChanged: (withPartners) => ref.read(mapStateProvider.notifier).switchWithPartners(withPartners),
|
||||
),
|
||||
MapTimeDropDown(
|
||||
relativeTime: mapState.relativeDays,
|
||||
onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time),
|
||||
),
|
||||
if (useCustomRange) ...[
|
||||
MapTimeRange(
|
||||
timeRange: mapState.timeRange,
|
||||
onChanged: (range) {
|
||||
ref.read(mapStateProvider.notifier).setTimeRange(range);
|
||||
},
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: () => setState(() {
|
||||
useCustomRange = false;
|
||||
ref.read(mapStateProvider.notifier).setRelativeTime(0);
|
||||
ref.read(mapStateProvider.notifier).setTimeRange(const TimeRange());
|
||||
}),
|
||||
child: Text(context.t.remove_custom_date_range),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
MapTimeDropDown(
|
||||
relativeTime: mapState.relativeDays,
|
||||
onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: () => setState(() {
|
||||
useCustomRange = true;
|
||||
ref.read(mapStateProvider.notifier).setRelativeTime(0);
|
||||
ref.read(mapStateProvider.notifier).setTimeRange(const TimeRange());
|
||||
}),
|
||||
child: Text(context.t.use_custom_date_range),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -11,7 +11,7 @@ import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/notification_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
@@ -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/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,
|
||||
];
|
||||
}
|
||||
}
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,11 +36,12 @@ 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});
|
||||
const ActionResult({required this.count, required this.success, this.error, this.remoteAssetIds = const []});
|
||||
|
||||
@override
|
||||
String toString() => 'ActionResult(count: $count, success: $success, error: $error)';
|
||||
String toString() => 'ActionResult(count: $count, success: $success, error: $error, remoteAssetIds: $remoteAssetIds)';
|
||||
}
|
||||
|
||||
class ActionNotifier extends Notifier<void> {
|
||||
@@ -554,10 +555,14 @@ 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) {
|
||||
@@ -574,6 +579,7 @@ 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];
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'package:immich_mobile/domain/models/person.model.dart';
|
||||
import 'package:immich_mobile/domain/services/people.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/people.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
|
||||
import 'package:immich_mobile/repositories/person_api.repository.dart';
|
||||
|
||||
final driftPeopleRepositoryProvider = Provider<DriftPeopleRepository>(
|
||||
@@ -21,6 +20,5 @@ final driftPeopleAssetProvider = FutureProvider.family<List<DriftPerson>, String
|
||||
|
||||
final driftGetAllPeopleProvider = FutureProvider<List<DriftPerson>>((ref) async {
|
||||
final service = ref.watch(driftPeopleServiceProvider);
|
||||
final prefs = await ref.watch(userMetadataPreferencesProvider.future);
|
||||
return service.getAllPeople(minFaces: prefs?.minimumFaces ?? 3);
|
||||
return service.getAllPeople();
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ 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';
|
||||
@@ -153,7 +154,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
|
||||
Future<RemoteAlbum?> updateAlbum(
|
||||
String albumId, {
|
||||
String? name,
|
||||
String? description,
|
||||
Optional<String?> description = const Optional.absent(),
|
||||
String? thumbnailAssetId,
|
||||
bool? isActivityEnabled,
|
||||
AlbumAssetOrder? order,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/user_metadata.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart';
|
||||
@@ -20,5 +19,6 @@ final userMetadataProvider = FutureProvider<List<UserMetadata>>((ref) async {
|
||||
|
||||
final userMetadataPreferencesProvider = FutureProvider<Preferences?>((ref) async {
|
||||
final metadataList = await ref.watch(userMetadataProvider.future);
|
||||
return metadataList.firstWhereOrNull((meta) => meta.preferences != null)?.preferences;
|
||||
final metadataWithPrefs = metadataList.firstWhere((meta) => meta.preferences != null);
|
||||
return metadataWithPrefs.preferences;
|
||||
});
|
||||
|
||||
+26
@@ -1,6 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/platform/permission_api.g.dart' as pm;
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class NotificationPermissionNotifier extends StateNotifier<PermissionStatus> {
|
||||
@@ -39,3 +42,26 @@ class NotificationPermissionNotifier extends StateNotifier<PermissionStatus> {
|
||||
final notificationPermissionProvider = StateNotifierProvider<NotificationPermissionNotifier, PermissionStatus>(
|
||||
(ref) => NotificationPermissionNotifier(),
|
||||
);
|
||||
|
||||
final batteryOptimizationProvider = AsyncNotifierProvider<BatteryOptimizationNotifier, PermissionStatus>(
|
||||
BatteryOptimizationNotifier.new,
|
||||
);
|
||||
|
||||
class BatteryOptimizationNotifier extends AsyncNotifier<PermissionStatus> {
|
||||
Future<PermissionStatus> getBatteryOptimizationPermission() async {
|
||||
final isIgnoring = await ref.read(permissionApiProvider).isIgnoringBatteryOptimizations().then((p) => p.toStatus());
|
||||
state = AsyncValue.data(isIgnoring);
|
||||
return isIgnoring;
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOr<PermissionStatus> build() => getBatteryOptimizationPermission();
|
||||
}
|
||||
|
||||
extension on pm.PermissionStatus {
|
||||
PermissionStatus toStatus() => switch (this) {
|
||||
pm.PermissionStatus.granted => PermissionStatus.granted,
|
||||
pm.PermissionStatus.denied => PermissionStatus.denied,
|
||||
pm.PermissionStatus.permanentlyDenied => PermissionStatus.permanentlyDenied,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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,
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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 {}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@ class DriftAlbumApiRepository extends ApiRepository {
|
||||
String albumId,
|
||||
UserDto owner, {
|
||||
String? name,
|
||||
String? description,
|
||||
Optional<String?> description = const Optional.absent(),
|
||||
String? thumbnailAssetId,
|
||||
bool? isActivityEnabled,
|
||||
AlbumAssetOrder? order,
|
||||
@@ -86,7 +86,7 @@ class DriftAlbumApiRepository extends ApiRepository {
|
||||
albumId,
|
||||
UpdateAlbumDto(
|
||||
albumName: name == null ? const Optional.absent() : Optional.present(name),
|
||||
description: description == null ? const Optional.absent() : Optional.present(description),
|
||||
description: description,
|
||||
albumThumbnailAssetId: thumbnailAssetId == null
|
||||
? const Optional.absent()
|
||||
: Optional.present(thumbnailAssetId),
|
||||
|
||||
@@ -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)? onSuccess,
|
||||
void Function(String fileId, String remoteAssetId)? onSuccess,
|
||||
void Function(String fileId, String errorMessage)? onError,
|
||||
}) async {
|
||||
if (files.isEmpty) {
|
||||
@@ -171,7 +171,7 @@ class ForegroundUploadService {
|
||||
);
|
||||
|
||||
if (result.isSuccess) {
|
||||
onSuccess?.call(fileId);
|
||||
onSuccess?.call(fileId, result.remoteAssetId!);
|
||||
} else if (!result.isCancelled && result.errorMessage != null) {
|
||||
onError?.call(fileId, result.errorMessage!);
|
||||
}
|
||||
|
||||
@@ -88,11 +88,10 @@ class SharedLinkService {
|
||||
required bool? showMeta,
|
||||
required bool? allowDownload,
|
||||
required bool? allowUpload,
|
||||
bool? changeExpiry = false,
|
||||
String? description,
|
||||
String? password,
|
||||
Optional<String?> password = const Optional.absent(),
|
||||
Optional<String?> description = const Optional.absent(),
|
||||
String? slug,
|
||||
DateTime? expiresAt,
|
||||
Optional<DateTime?> expiresAt = const Optional.absent(),
|
||||
}) async {
|
||||
try {
|
||||
final responseDto = await _apiService.sharedLinksApi.updateSharedLink(
|
||||
@@ -101,11 +100,10 @@ class SharedLinkService {
|
||||
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),
|
||||
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),
|
||||
password: password,
|
||||
description: description,
|
||||
expiresAt: expiresAt,
|
||||
slug: slug == null ? const Optional.absent() : Optional.present(slug),
|
||||
changeExpiryTime: changeExpiry == null ? const Optional.absent() : Optional.present(changeExpiry),
|
||||
),
|
||||
);
|
||||
if (responseDto != null) {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,17 @@ sealed class Option<T> {
|
||||
None() => onNone(),
|
||||
};
|
||||
|
||||
Option<U> flatMap<U>(Option<U> Function(T value) f) => switch (this) {
|
||||
Some(:final value) => f(value),
|
||||
None() => const Option.none(),
|
||||
};
|
||||
|
||||
void ifPresent(void Function(T value) f) {
|
||||
if (this case Some(:final value)) {
|
||||
f(value);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => switch (this) {
|
||||
Some(:final value) => 'Some($value)',
|
||||
|
||||
@@ -21,6 +21,7 @@ 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';
|
||||
@@ -182,9 +183,11 @@ 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) {
|
||||
@@ -259,7 +262,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
}
|
||||
unawaited(handleSyncFlow());
|
||||
ref.read(websocketProvider.notifier).connect();
|
||||
unawaited(context.replaceRoute(const TabShellRoute()));
|
||||
unawaited(context.router.replaceAll([const TabShellRoute()]));
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -346,7 +349,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
await getManageMediaPermission();
|
||||
}
|
||||
unawaited(handleSyncFlow());
|
||||
unawaited(context.replaceRoute(const TabShellRoute()));
|
||||
unawaited(context.router.replaceAll([const TabShellRoute()]));
|
||||
return;
|
||||
}
|
||||
} catch (error, stack) {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/time_range.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
|
||||
class MapTimeRange extends StatelessWidget {
|
||||
const MapTimeRange({super.key, required this.timeRange, required this.onChanged});
|
||||
|
||||
final TimeRange timeRange;
|
||||
final Function(TimeRange) onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text(context.t.date_after),
|
||||
subtitle: Text(
|
||||
timeRange.from.fold(
|
||||
(from) => DateFormat.yMMMd(context.locale.toLanguageTag()).add_jm().format(from),
|
||||
() => context.t.not_set,
|
||||
),
|
||||
),
|
||||
trailing: timeRange.from.isSome
|
||||
? IconButton(icon: const Icon(Icons.close), onPressed: () => onChanged(timeRange.clearFrom()))
|
||||
: null,
|
||||
onTap: () async {
|
||||
final initial = timeRange.from.unwrapOrNull ?? DateTime.now();
|
||||
final currentTo = timeRange.to.unwrapOrNull;
|
||||
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: currentTo != null && initial.isAfter(currentTo) ? currentTo : initial,
|
||||
firstDate: DateTime(1970),
|
||||
lastDate: currentTo ?? DateTime.now(),
|
||||
);
|
||||
|
||||
if (picked != null) {
|
||||
onChanged(timeRange.copyWith(from: Option.some(picked)));
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text(context.t.date_before),
|
||||
subtitle: Text(
|
||||
timeRange.to.fold<String>(
|
||||
(to) => DateFormat.yMMMd(context.locale.toLanguageTag()).add_jm().format(to),
|
||||
() => context.t.not_set,
|
||||
),
|
||||
),
|
||||
trailing: timeRange.to.isSome
|
||||
? IconButton(icon: const Icon(Icons.close), onPressed: () => onChanged(timeRange.clearTo()))
|
||||
: null,
|
||||
onTap: () async {
|
||||
final initial = timeRange.to.unwrapOrNull ?? DateTime.now();
|
||||
final currentFrom = timeRange.from.unwrapOrNull;
|
||||
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: currentFrom != null && initial.isBefore(currentFrom) ? currentFrom : initial,
|
||||
firstDate: currentFrom ?? DateTime(1970),
|
||||
lastDate: DateTime.now(),
|
||||
);
|
||||
|
||||
if (picked != null) {
|
||||
onChanged(timeRange.copyWith(to: Option.some(picked)));
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/notification_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/permission.provider.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_button_list_tile.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# @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"
|
||||
+24
-14
@@ -1,3 +1,19 @@
|
||||
[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"
|
||||
@@ -22,14 +38,8 @@ run = "dart run build_runner watch --delete-conflicting-outputs"
|
||||
alias = "pigeon"
|
||||
description = "Generate pigeon platform code"
|
||||
run = [
|
||||
"dart run pigeon --input pigeon/native_sync_api.dart",
|
||||
"dart run pigeon --input pigeon/local_image_api.dart",
|
||||
"dart run pigeon --input pigeon/remote_image_api.dart",
|
||||
"dart run pigeon --input pigeon/background_worker_api.dart",
|
||||
"dart run pigeon --input pigeon/background_worker_lock_api.dart",
|
||||
"dart run pigeon --input pigeon/connectivity_api.dart",
|
||||
"dart run pigeon --input pigeon/network_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",
|
||||
"ls pigeon/*.dart | xargs -n1 -P4 -I{} dart run pigeon --input {}",
|
||||
"dart format lib/platform/",
|
||||
]
|
||||
|
||||
[tasks."codegen:translation"]
|
||||
@@ -132,10 +142,10 @@ run = "dcm fix lib"
|
||||
|
||||
[tasks.checklist]
|
||||
run = [
|
||||
{task = "codegen:pigeon" },
|
||||
{task = "codegen:dart" },
|
||||
{task = "codegen:translation" },
|
||||
{task = "analyze" },
|
||||
{task = "format" },
|
||||
{task = "test" },
|
||||
{ task = "codegen:pigeon" },
|
||||
{ task = "codegen:dart" },
|
||||
{ task = "codegen:translation" },
|
||||
{ task = "analyze" },
|
||||
{ task = "format" },
|
||||
{ task = "test" },
|
||||
]
|
||||
|
||||
+1
-18
@@ -15,7 +15,6 @@ class SharedLinkEditDto {
|
||||
SharedLinkEditDto({
|
||||
this.allowDownload = const Optional.absent(),
|
||||
this.allowUpload = const Optional.absent(),
|
||||
this.changeExpiryTime = const Optional.absent(),
|
||||
this.description = const Optional.absent(),
|
||||
this.expiresAt = const Optional.absent(),
|
||||
this.password = const Optional.absent(),
|
||||
@@ -41,15 +40,6 @@ class SharedLinkEditDto {
|
||||
///
|
||||
Optional<bool?> allowUpload;
|
||||
|
||||
/// Whether to change the expiry time. Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this.
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// 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?> changeExpiryTime;
|
||||
|
||||
/// Link description
|
||||
Optional<String?> description;
|
||||
|
||||
@@ -75,7 +65,6 @@ class SharedLinkEditDto {
|
||||
bool operator ==(Object other) => identical(this, other) || other is SharedLinkEditDto &&
|
||||
other.allowDownload == allowDownload &&
|
||||
other.allowUpload == allowUpload &&
|
||||
other.changeExpiryTime == changeExpiryTime &&
|
||||
other.description == description &&
|
||||
other.expiresAt == expiresAt &&
|
||||
other.password == password &&
|
||||
@@ -87,7 +76,6 @@ class SharedLinkEditDto {
|
||||
// ignore: unnecessary_parenthesis
|
||||
(allowDownload == null ? 0 : allowDownload!.hashCode) +
|
||||
(allowUpload == null ? 0 : allowUpload!.hashCode) +
|
||||
(changeExpiryTime == null ? 0 : changeExpiryTime!.hashCode) +
|
||||
(description == null ? 0 : description!.hashCode) +
|
||||
(expiresAt == null ? 0 : expiresAt!.hashCode) +
|
||||
(password == null ? 0 : password!.hashCode) +
|
||||
@@ -95,7 +83,7 @@ class SharedLinkEditDto {
|
||||
(slug == null ? 0 : slug!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SharedLinkEditDto[allowDownload=$allowDownload, allowUpload=$allowUpload, changeExpiryTime=$changeExpiryTime, description=$description, expiresAt=$expiresAt, password=$password, showMetadata=$showMetadata, slug=$slug]';
|
||||
String toString() => 'SharedLinkEditDto[allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, expiresAt=$expiresAt, password=$password, showMetadata=$showMetadata, slug=$slug]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -107,10 +95,6 @@ class SharedLinkEditDto {
|
||||
final value = this.allowUpload.value;
|
||||
json[r'allowUpload'] = value;
|
||||
}
|
||||
if (this.changeExpiryTime.isPresent) {
|
||||
final value = this.changeExpiryTime.value;
|
||||
json[r'changeExpiryTime'] = value;
|
||||
}
|
||||
if (this.description.isPresent) {
|
||||
final value = this.description.value;
|
||||
json[r'description'] = value;
|
||||
@@ -147,7 +131,6 @@ class SharedLinkEditDto {
|
||||
return SharedLinkEditDto(
|
||||
allowDownload: json.containsKey(r'allowDownload') ? Optional.present(mapValueOfType<bool>(json, r'allowDownload')) : const Optional.absent(),
|
||||
allowUpload: json.containsKey(r'allowUpload') ? Optional.present(mapValueOfType<bool>(json, r'allowUpload')) : const Optional.absent(),
|
||||
changeExpiryTime: json.containsKey(r'changeExpiryTime') ? Optional.present(mapValueOfType<bool>(json, r'changeExpiryTime')) : const Optional.absent(),
|
||||
description: json.containsKey(r'description') ? Optional.present(mapValueOfType<String>(json, r'description')) : const Optional.absent(),
|
||||
expiresAt: json.containsKey(r'expiresAt') ? Optional.present(mapDateTime(json, r'expiresAt', 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))$/')) : const Optional.absent(),
|
||||
password: json.containsKey(r'password') ? Optional.present(mapValueOfType<String>(json, r'password')) : const Optional.absent(),
|
||||
|
||||
@@ -5,8 +5,7 @@ import 'package:pigeon/pigeon.dart';
|
||||
dartOut: 'lib/platform/local_image_api.g.dart',
|
||||
swiftOut: 'ios/Runner/Images/LocalImages.g.swift',
|
||||
swiftOptions: SwiftOptions(includeErrorClass: false),
|
||||
kotlinOut:
|
||||
'android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt',
|
||||
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt',
|
||||
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.images'),
|
||||
dartOptions: DartOptions(),
|
||||
dartPackageName: 'immich_mobile',
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'package:pigeon/pigeon.dart';
|
||||
|
||||
enum PermissionStatus { granted, denied, permanentlyDenied }
|
||||
|
||||
@ConfigurePigeon(
|
||||
PigeonOptions(
|
||||
dartOut: 'lib/platform/permission_api.g.dart',
|
||||
swiftOut: 'ios/Runner/Permission/PermissionApi.g.swift',
|
||||
swiftOptions: SwiftOptions(),
|
||||
swiftOptions: SwiftOptions(includeErrorClass: false),
|
||||
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt',
|
||||
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.permission'),
|
||||
dartOptions: DartOptions(),
|
||||
@@ -13,6 +15,8 @@ import 'package:pigeon/pigeon.dart';
|
||||
)
|
||||
@HostApi()
|
||||
abstract class PermissionApi {
|
||||
PermissionStatus isIgnoringBatteryOptimizations();
|
||||
|
||||
bool hasManageMediaPermission();
|
||||
|
||||
@async
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:pigeon/pigeon.dart';
|
||||
|
||||
@ConfigurePigeon(
|
||||
PigeonOptions(
|
||||
dartOut: 'lib/platform/view_intent_api.g.dart',
|
||||
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntent.g.kt',
|
||||
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.viewintent'),
|
||||
dartOptions: DartOptions(),
|
||||
dartPackageName: 'immich_mobile',
|
||||
),
|
||||
)
|
||||
class ViewIntentPayload {
|
||||
final String? path;
|
||||
final String mimeType;
|
||||
final String? localAssetId;
|
||||
|
||||
const ViewIntentPayload({this.path, required this.mimeType, this.localAssetId});
|
||||
}
|
||||
|
||||
@HostApi()
|
||||
abstract class ViewIntentHostApi {
|
||||
@async
|
||||
ViewIntentPayload? consumeViewIntent();
|
||||
}
|
||||
+4
@@ -31,6 +31,7 @@ import 'schema_v24.dart' as v24;
|
||||
import 'schema_v25.dart' as v25;
|
||||
import 'schema_v26.dart' as v26;
|
||||
import 'schema_v27.dart' as v27;
|
||||
import 'schema_v28.dart' as v28;
|
||||
|
||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
@override
|
||||
@@ -90,6 +91,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
return v26.DatabaseAtV26(db);
|
||||
case 27:
|
||||
return v27.DatabaseAtV27(db);
|
||||
case 28:
|
||||
return v28.DatabaseAtV28(db);
|
||||
default:
|
||||
throw MissingSchemaException(version, versions);
|
||||
}
|
||||
@@ -123,5 +126,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
25,
|
||||
26,
|
||||
27,
|
||||
28,
|
||||
];
|
||||
}
|
||||
|
||||
+9389
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,261 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
||||
import 'package:immich_mobile/models/auth/auth_state.model.dart';
|
||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler_android.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:immich_mobile/services/auth.service.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/secure_storage.service.dart';
|
||||
import 'package:immich_mobile/services/widget.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockViewIntentHostApi extends Mock implements ViewIntentHostApi {}
|
||||
|
||||
class MockViewIntentAssetResolver extends Mock implements ViewIntentAssetResolver {}
|
||||
|
||||
class MockAppRouter extends Mock implements AppRouter {}
|
||||
|
||||
class MockAuthService extends Mock implements AuthService {}
|
||||
|
||||
class MockApiService extends Mock implements ApiService {}
|
||||
|
||||
class MockUserService extends Mock implements UserService {}
|
||||
|
||||
class MockSecureStorageService extends Mock implements SecureStorageService {}
|
||||
|
||||
class MockWidgetService extends Mock implements WidgetService {}
|
||||
|
||||
class FakePageRouteInfo extends Fake implements PageRouteInfo<dynamic> {}
|
||||
|
||||
class FakeTimelineService extends Fake implements TimelineService {}
|
||||
|
||||
class TestViewIntentService extends ViewIntentService {
|
||||
ViewIntentPayload? consumedAttachment;
|
||||
int cleanupStaleTempFilesCalls = 0;
|
||||
int cleanupManagedTempFileCalls = 0;
|
||||
final List<String> managedTempPaths = [];
|
||||
|
||||
TestViewIntentService() : super(MockViewIntentHostApi());
|
||||
|
||||
@override
|
||||
Future<ViewIntentPayload?> consumeViewIntent() async => consumedAttachment;
|
||||
|
||||
@override
|
||||
Future<void> cleanupStaleTempFiles() async {
|
||||
cleanupStaleTempFilesCalls++;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cleanupManagedTempFile() async {
|
||||
cleanupManagedTempFileCalls++;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setManagedTempFilePath(String path) async {
|
||||
managedTempPaths.add(path);
|
||||
}
|
||||
}
|
||||
|
||||
class TestAuthNotifier extends AuthNotifier {
|
||||
TestAuthNotifier(Ref ref, AuthState initial)
|
||||
: super(
|
||||
MockAuthService(),
|
||||
MockApiService(),
|
||||
MockUserService(),
|
||||
MockSecureStorageService(),
|
||||
MockWidgetService(),
|
||||
ref,
|
||||
) {
|
||||
state = initial;
|
||||
}
|
||||
|
||||
void setAuthenticated(bool isAuthenticated) {
|
||||
state = state.copyWith(isAuthenticated: isAuthenticated);
|
||||
}
|
||||
}
|
||||
|
||||
final _handlerProvider = Provider<AndroidViewIntentHandler>((ref) => AndroidViewIntentHandler(ref));
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late TestViewIntentService viewIntentService;
|
||||
late MockViewIntentAssetResolver resolver;
|
||||
late MockAppRouter router;
|
||||
late TestAuthNotifier authNotifier;
|
||||
late ProviderContainer container;
|
||||
late AndroidViewIntentHandler handler;
|
||||
late ViewIntentPayload payload;
|
||||
late LocalAsset deepLinkAsset;
|
||||
late TimelineService deepLinkTimelineService;
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(FakePageRouteInfo());
|
||||
registerFallbackValue(<PageRouteInfo<dynamic>>[]);
|
||||
registerFallbackValue(FakeTimelineService());
|
||||
registerFallbackValue(
|
||||
ViewIntentPayload(path: '/tmp/fallback.jpg', mimeType: 'image/jpeg', localAssetId: 'fallback'),
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
viewIntentService = TestViewIntentService();
|
||||
resolver = MockViewIntentAssetResolver();
|
||||
router = MockAppRouter();
|
||||
payload = ViewIntentPayload(path: '/tmp/incoming.jpg', mimeType: 'image/jpeg', localAssetId: 'local-1');
|
||||
deepLinkAsset = _localAsset(id: 'local-1');
|
||||
deepLinkTimelineService = await _createReadyTimelineService([deepLinkAsset], TimelineOrigin.deepLink);
|
||||
|
||||
when(() => router.replaceAll(any())).thenAnswer((_) async {});
|
||||
|
||||
container = ProviderContainer(
|
||||
overrides: [
|
||||
viewIntentServiceProvider.overrideWithValue(viewIntentService),
|
||||
viewIntentAssetResolverProvider.overrideWithValue(resolver),
|
||||
appRouterProvider.overrideWithValue(router),
|
||||
authProvider.overrideWith((ref) {
|
||||
authNotifier = TestAuthNotifier(ref, _authState(isAuthenticated: true));
|
||||
return authNotifier;
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
authNotifier = container.read(authProvider.notifier) as TestAuthNotifier;
|
||||
handler = container.read(_handlerProvider);
|
||||
|
||||
addTearDown(() async {
|
||||
await deepLinkTimelineService.dispose();
|
||||
container.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('handle defers unauthenticated attachment', () async {
|
||||
authNotifier.setAuthenticated(false);
|
||||
|
||||
await handler.handle(payload);
|
||||
|
||||
expect(container.read(viewIntentPendingProvider), payload);
|
||||
verifyNever(() => resolver.resolve(any()));
|
||||
});
|
||||
|
||||
testWidgets('flushDeferredViewIntent consumes the pending attachment and routes the viewer', (tester) async {
|
||||
authNotifier.setAuthenticated(false);
|
||||
container.read(viewIntentPendingProvider.notifier).defer(payload);
|
||||
authNotifier.setAuthenticated(true);
|
||||
|
||||
when(() => resolver.resolve(payload)).thenAnswer((_) async {
|
||||
return ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService);
|
||||
});
|
||||
|
||||
unawaited(handler.flushDeferredViewIntent());
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.idle();
|
||||
|
||||
expect(container.read(viewIntentPendingProvider), isNull);
|
||||
verify(() => resolver.resolve(payload)).called(1);
|
||||
});
|
||||
|
||||
test('flushDeferredViewIntent does nothing when there is no pending attachment', () async {
|
||||
await handler.flushDeferredViewIntent();
|
||||
|
||||
verifyNever(() => resolver.resolve(any()));
|
||||
});
|
||||
|
||||
test('onAppResumed cleans stale temp files when no attachment is present', () async {
|
||||
viewIntentService.consumedAttachment = null;
|
||||
|
||||
await handler.onAppResumed();
|
||||
|
||||
expect(viewIntentService.cleanupStaleTempFilesCalls, 1);
|
||||
verifyNever(() => resolver.resolve(any()));
|
||||
});
|
||||
|
||||
test('onAppResumed does not clean stale temp files while pending attachment exists', () async {
|
||||
viewIntentService.consumedAttachment = null;
|
||||
container.read(viewIntentPendingProvider.notifier).defer(payload);
|
||||
|
||||
await handler.onAppResumed();
|
||||
|
||||
expect(viewIntentService.cleanupStaleTempFilesCalls, 0);
|
||||
verifyNever(() => resolver.resolve(any()));
|
||||
});
|
||||
|
||||
testWidgets('onAppResumed handles attachment immediately when authenticated', (tester) async {
|
||||
viewIntentService.consumedAttachment = payload;
|
||||
when(() => resolver.resolve(payload)).thenAnswer(
|
||||
(_) async => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService),
|
||||
);
|
||||
|
||||
unawaited(handler.onAppResumed());
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.idle();
|
||||
|
||||
verify(() => resolver.resolve(payload)).called(1);
|
||||
// Routes the user to [TabShell, AssetViewer] so back-press lands on the
|
||||
// main timeline — mirrors the home-screen widget navigation pattern.
|
||||
final captured = verify(() => router.replaceAll(captureAny())).captured;
|
||||
expect(captured, hasLength(1));
|
||||
final routes = captured.single as List<PageRouteInfo<dynamic>>;
|
||||
expect(routes, hasLength(2));
|
||||
expect(routes[0].routeName, TabShellRoute.name);
|
||||
expect(routes[1].routeName, AssetViewerRoute.name);
|
||||
});
|
||||
}
|
||||
|
||||
AuthState _authState({required bool isAuthenticated}) {
|
||||
return AuthState(
|
||||
deviceId: 'device-1',
|
||||
userId: 'user-1',
|
||||
userEmail: 'user@example.com',
|
||||
isAuthenticated: isAuthenticated,
|
||||
name: 'User',
|
||||
isAdmin: false,
|
||||
profileImagePath: '',
|
||||
);
|
||||
}
|
||||
|
||||
LocalAsset _localAsset({required String id}) {
|
||||
return LocalAsset(
|
||||
id: id,
|
||||
name: '$id.jpg',
|
||||
checksum: 'checksum-1',
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2026, 4, 20),
|
||||
updatedAt: DateTime(2026, 4, 20),
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
TimelineService _timelineServiceFromAssets(List<BaseAsset> assets, TimelineOrigin origin) {
|
||||
return TimelineService((
|
||||
assetSource: (index, count) async => assets.skip(index).take(count).toList(),
|
||||
bucketSource: () => Stream.value([Bucket(assetCount: assets.length)]),
|
||||
origin: origin,
|
||||
));
|
||||
}
|
||||
|
||||
Future<TimelineService> _createReadyTimelineService(List<BaseAsset> assets, TimelineOrigin origin) async {
|
||||
final timelineService = _timelineServiceFromAssets(assets, origin);
|
||||
// Spin a few async ticks so the internal bucket subscription has populated
|
||||
// the buffer before tests start asserting against totalAssets.
|
||||
for (var i = 0; i < 20 && timelineService.totalAssets != assets.length; i++) {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
return timelineService;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
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_pending.provider.dart';
|
||||
|
||||
void main() {
|
||||
late DateTime now;
|
||||
late ProviderContainer container;
|
||||
|
||||
final attachment = ViewIntentPayload(
|
||||
path: '/tmp/file.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
localAssetId: '42',
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
now = DateTime(2026, 4, 17, 12);
|
||||
container = ProviderContainer(
|
||||
overrides: [viewIntentNowProvider.overrideWithValue(() => now)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
});
|
||||
|
||||
test('defer stores pending attachment', () {
|
||||
container.read(viewIntentPendingProvider.notifier).defer(attachment);
|
||||
|
||||
expect(container.read(viewIntentPendingProvider), attachment);
|
||||
});
|
||||
|
||||
test('takeIfFresh returns pending attachment once', () {
|
||||
container.read(viewIntentPendingProvider.notifier).defer(attachment);
|
||||
|
||||
final first = container.read(viewIntentPendingProvider.notifier).takeIfFresh();
|
||||
final second = container.read(viewIntentPendingProvider.notifier).takeIfFresh();
|
||||
|
||||
expect(first, attachment);
|
||||
expect(second, isNull);
|
||||
});
|
||||
|
||||
test('takeIfFresh drops expired attachment', () {
|
||||
container.read(viewIntentPendingProvider.notifier).defer(attachment);
|
||||
now = now.add(const Duration(minutes: 11));
|
||||
|
||||
final result = container.read(viewIntentPendingProvider.notifier).takeIfFresh();
|
||||
|
||||
expect(result, isNull);
|
||||
expect(container.read(viewIntentPendingProvider), isNull);
|
||||
});
|
||||
|
||||
test('newer deferred attachment replaces older one', () {
|
||||
final newerAttachment = ViewIntentPayload(
|
||||
path: '/tmp/file-2.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
localAssetId: '43',
|
||||
);
|
||||
|
||||
container.read(viewIntentPendingProvider.notifier).defer(attachment);
|
||||
container.read(viewIntentPendingProvider.notifier).defer(newerAttachment);
|
||||
|
||||
final result = container.read(viewIntentPendingProvider.notifier).takeIfFresh();
|
||||
|
||||
expect(result, newerAttachment);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.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/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../infrastructure/repository.mock.dart';
|
||||
|
||||
class MockTimelineFactory extends Mock implements TimelineFactory {}
|
||||
|
||||
void main() {
|
||||
late MockDriftLocalAssetRepository mockLocalAssetRepository;
|
||||
late MockTimelineFactory timelineFactory;
|
||||
late List<TimelineService> createdTimelineServices;
|
||||
late ProviderContainer container;
|
||||
|
||||
setUp(() {
|
||||
mockLocalAssetRepository = MockDriftLocalAssetRepository();
|
||||
timelineFactory = MockTimelineFactory();
|
||||
createdTimelineServices = [];
|
||||
|
||||
when(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLink)).thenAnswer((invocation) {
|
||||
final assets = List<BaseAsset>.from(invocation.positionalArguments[0] as List<BaseAsset>);
|
||||
final timelineService = _timelineServiceFromAssets(assets, TimelineOrigin.deepLink);
|
||||
createdTimelineServices.add(timelineService);
|
||||
return timelineService;
|
||||
});
|
||||
|
||||
container = ProviderContainer(
|
||||
overrides: [
|
||||
localAssetRepository.overrideWith((ref) => mockLocalAssetRepository),
|
||||
timelineFactoryProvider.overrideWith((ref) => timelineFactory),
|
||||
],
|
||||
);
|
||||
|
||||
addTearDown(() async {
|
||||
for (final timelineService in createdTimelineServices) {
|
||||
await timelineService.dispose();
|
||||
}
|
||||
container.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('returns DB-backed local asset wrapped in a 1-element deep-link timeline', () async {
|
||||
final localAsset = _localAsset(id: 'local-1', checksum: 'checksum-1');
|
||||
when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => localAsset);
|
||||
|
||||
final result = await _resolve(container, _payload(localAssetId: 'local-1'));
|
||||
|
||||
expect(result.asset, equals(localAsset));
|
||||
expect(result.timelineService.origin, TimelineOrigin.deepLink);
|
||||
expect(result.viewIntentFilePath, isNull, reason: 'DB-backed assets carry their own source — no temp file needed');
|
||||
});
|
||||
|
||||
test('returns transient asset with temp file path when localAssetId has no DB row', () async {
|
||||
when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => null);
|
||||
|
||||
final result = await _resolve(container, _payload(localAssetId: 'local-1', path: '/tmp/incoming.jpg'));
|
||||
|
||||
expect(result.asset, isA<LocalAsset>());
|
||||
expect(result.timelineService.origin, TimelineOrigin.deepLink);
|
||||
expect(result.viewIntentFilePath, '/tmp/incoming.jpg');
|
||||
});
|
||||
|
||||
test('returns transient asset for path-only attachment', () async {
|
||||
final result = await _resolve(
|
||||
container,
|
||||
_payload(localAssetId: null, path: '/tmp/incoming.webp', mimeType: 'image/webp'),
|
||||
);
|
||||
|
||||
expect(result.asset, isA<LocalAsset>());
|
||||
expect(result.timelineService.origin, TimelineOrigin.deepLink);
|
||||
expect(result.viewIntentFilePath, '/tmp/incoming.webp');
|
||||
|
||||
final asset = result.asset as LocalAsset;
|
||||
expect(asset.localId, startsWith('-'));
|
||||
expect(asset.name, 'incoming.webp');
|
||||
expect(asset.playbackStyle, AssetPlaybackStyle.imageAnimated);
|
||||
});
|
||||
|
||||
test('throws when neither localAssetId nor path is provided', () async {
|
||||
await expectLater(
|
||||
_resolve(container, _payload(localAssetId: null, path: null)),
|
||||
throwsA(isA<StateError>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<ViewIntentResolvedAsset> _resolve(ProviderContainer container, ViewIntentPayload payload) {
|
||||
return container.read(viewIntentAssetResolverProvider).resolve(payload);
|
||||
}
|
||||
|
||||
ViewIntentPayload _payload({String? localAssetId = 'local-1', String? path, String mimeType = 'image/jpeg'}) {
|
||||
return ViewIntentPayload(path: path, mimeType: mimeType, localAssetId: localAssetId);
|
||||
}
|
||||
|
||||
LocalAsset _localAsset({required String id, String? checksum}) {
|
||||
return LocalAsset(
|
||||
id: id,
|
||||
name: '$id.jpg',
|
||||
checksum: checksum,
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2026, 4, 20),
|
||||
updatedAt: DateTime(2026, 4, 20),
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
TimelineService _timelineServiceFromAssets(List<BaseAsset> assets, TimelineOrigin origin) {
|
||||
return TimelineService((
|
||||
assetSource: (index, count) async => assets.skip(index).take(count).toList(),
|
||||
bucketSource: () => Stream.value([Bucket(assetCount: assets.length)]),
|
||||
origin: origin,
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
||||
import 'package:immich_mobile/services/view_intent.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockViewIntentHostApi extends Mock implements ViewIntentHostApi {}
|
||||
|
||||
void main() {
|
||||
late MockViewIntentHostApi hostApi;
|
||||
late ViewIntentService service;
|
||||
late Directory tempRoot;
|
||||
late Directory cacheDir;
|
||||
|
||||
final attachment = ViewIntentPayload(
|
||||
path: '/tmp/file.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
localAssetId: '42',
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
hostApi = MockViewIntentHostApi();
|
||||
tempRoot = Directory.systemTemp.createTempSync('view-intent-root');
|
||||
cacheDir = Directory('${tempRoot.path}/cache')..createSync();
|
||||
service = ViewIntentService(hostApi, temporaryDirectory: () async => cacheDir);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
clearInteractions(hostApi);
|
||||
if (await tempRoot.exists()) {
|
||||
await tempRoot.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
test('consumeViewIntent returns null when no attachment', () async {
|
||||
when(() => hostApi.consumeViewIntent()).thenAnswer((_) async => null);
|
||||
|
||||
final result = await service.consumeViewIntent();
|
||||
|
||||
expect(result, isNull);
|
||||
verify(() => hostApi.consumeViewIntent()).called(1);
|
||||
});
|
||||
|
||||
test('consumeViewIntent returns attachment when present', () async {
|
||||
when(() => hostApi.consumeViewIntent()).thenAnswer((_) async => attachment);
|
||||
|
||||
final result = await service.consumeViewIntent();
|
||||
|
||||
expect(result, attachment);
|
||||
verify(() => hostApi.consumeViewIntent()).called(1);
|
||||
});
|
||||
|
||||
test('consumeViewIntent swallows host api errors', () async {
|
||||
when(() => hostApi.consumeViewIntent()).thenThrow(Exception('boom'));
|
||||
|
||||
final result = await service.consumeViewIntent();
|
||||
|
||||
expect(result, isNull);
|
||||
verify(() => hostApi.consumeViewIntent()).called(1);
|
||||
});
|
||||
|
||||
test('setManagedTempFilePath cleans previous managed temp file', () async {
|
||||
final firstFile = File('${cacheDir.path}/view_intent_first.jpg')..writeAsStringSync('first');
|
||||
final secondFile = File('${cacheDir.path}/view_intent_second.jpg')..writeAsStringSync('second');
|
||||
|
||||
await service.setManagedTempFilePath(firstFile.path);
|
||||
await service.setManagedTempFilePath(secondFile.path);
|
||||
|
||||
expect(await firstFile.exists(), isFalse);
|
||||
expect(await secondFile.exists(), isTrue);
|
||||
|
||||
await service.cleanupManagedTempFile();
|
||||
expect(await secondFile.exists(), isFalse);
|
||||
});
|
||||
|
||||
test('cleanupTempFile defers deletion while an upload is active', () async {
|
||||
final tempFile = File('${cacheDir.path}/view_intent_in_flight.jpg')..writeAsStringSync('bytes');
|
||||
|
||||
service.markUploadActive(tempFile.path);
|
||||
await service.cleanupTempFile(tempFile.path);
|
||||
|
||||
expect(await tempFile.exists(), isTrue, reason: 'active uploads block cleanup');
|
||||
|
||||
await service.markUploadInactive(tempFile.path);
|
||||
expect(await tempFile.exists(), isFalse);
|
||||
});
|
||||
|
||||
test('cleanupTempFile ignores non-managed paths', () async {
|
||||
final nonManagedFile = File('${tempRoot.path}/plain_file.jpg')..writeAsStringSync('content');
|
||||
|
||||
await service.cleanupTempFile(nonManagedFile.path);
|
||||
|
||||
expect(await nonManagedFile.exists(), isTrue);
|
||||
});
|
||||
|
||||
test('cleanupStaleTempFiles removes view-intent temp files and keeps unrelated files', () async {
|
||||
final firstFile = File('${cacheDir.path}/view_intent_first.jpg')..writeAsStringSync('first');
|
||||
final secondFile = File('${cacheDir.path}/view_intent_second.jpg')..writeAsStringSync('second');
|
||||
final unrelatedFile = File('${cacheDir.path}/plain_file.jpg')..writeAsStringSync('plain');
|
||||
|
||||
await service.cleanupStaleTempFiles();
|
||||
|
||||
expect(await firstFile.exists(), isFalse);
|
||||
expect(await secondFile.exists(), isFalse);
|
||||
expect(await unrelatedFile.exists(), isTrue);
|
||||
});
|
||||
|
||||
test('cleanupStaleTempFiles skips paths with active uploads', () async {
|
||||
final stale = File('${cacheDir.path}/view_intent_stale.jpg')..writeAsStringSync('stale');
|
||||
final active = File('${cacheDir.path}/view_intent_active.jpg')..writeAsStringSync('active');
|
||||
service.markUploadActive(active.path);
|
||||
|
||||
await service.cleanupStaleTempFiles();
|
||||
|
||||
expect(await stale.exists(), isFalse);
|
||||
expect(await active.exists(), isTrue);
|
||||
});
|
||||
}
|
||||
@@ -22197,10 +22197,6 @@
|
||||
"description": "Allow uploads",
|
||||
"type": "boolean"
|
||||
},
|
||||
"changeExpiryTime": {
|
||||
"description": "Whether to change the expiry time. Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"description": "Link description",
|
||||
"nullable": true,
|
||||
|
||||
@@ -2192,8 +2192,6 @@ export type SharedLinkEditDto = {
|
||||
allowDownload?: boolean;
|
||||
/** Allow uploads */
|
||||
allowUpload?: boolean;
|
||||
/** Whether to change the expiry time. Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this. */
|
||||
changeExpiryTime?: boolean;
|
||||
/** Link description */
|
||||
description?: string | null;
|
||||
/** Expiration date */
|
||||
|
||||
@@ -53,16 +53,6 @@ describe(PersonController.name, () => {
|
||||
await request(ctx.getHttpServer()).post('/people');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should map an empty birthDate to null', async () => {
|
||||
await request(ctx.getHttpServer()).post('/people').send({ birthDate: '' });
|
||||
expect(service.create).toHaveBeenCalledWith(undefined, { birthDate: null });
|
||||
});
|
||||
|
||||
it('should map an empty color to null', async () => {
|
||||
await request(ctx.getHttpServer()).post('/people').send({ color: '' });
|
||||
expect(service.create).toHaveBeenCalledWith(undefined, { color: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /people', () => {
|
||||
@@ -153,12 +143,6 @@ describe(PersonController.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should map an empty birthDate to null', async () => {
|
||||
const id = factory.uuid();
|
||||
await request(ctx.getHttpServer()).put(`/people/${id}`).send({ birthDate: '' });
|
||||
expect(service.update).toHaveBeenCalledWith(undefined, id, { birthDate: null });
|
||||
});
|
||||
|
||||
it('should not accept an invalid birth date (false)', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/people/${factory.uuid()}`)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Body, Controller, Delete, Get, Header, HttpCode, HttpStatus, Post, Res } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Header, HttpCode, HttpStatus, Post, Req, Res } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { Request, Response } from 'express';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { SyncAckDeleteDto, SyncAckDto, SyncAckSetDto, SyncStreamDto } from 'src/dtos/sync.dto';
|
||||
@@ -27,12 +27,12 @@ export class SyncController {
|
||||
'Retrieve a JSON lines streamed response of changes for synchronization. This endpoint is used by the mobile app to efficiently stay up to date with changes.',
|
||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
||||
})
|
||||
async getSyncStream(@Auth() auth: AuthDto, @Res() res: Response, @Body() dto: SyncStreamDto) {
|
||||
async getSyncStream(@Auth() auth: AuthDto, @Req() req: Request, @Res() res: Response, @Body() dto: SyncStreamDto) {
|
||||
try {
|
||||
await this.service.stream(auth, res, dto);
|
||||
} catch (error: Error | any) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
this.errorService.handleError(res, error);
|
||||
this.errorService.handleError(req, res, error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,11 +63,5 @@ describe(TagController.name, () => {
|
||||
await request(ctx.getHttpServer()).put(`/tags/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow setting a null color via an empty string', async () => {
|
||||
const id = factory.uuid();
|
||||
await request(ctx.getHttpServer()).put(`/tags/${id}`).send({ color: '' });
|
||||
expect(service.update).toHaveBeenCalledWith(undefined, id, expect.objectContaining({ color: null }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { UserResponseSchema, mapUser } from 'src/dtos/user.dto';
|
||||
import { AlbumUserRole, AlbumUserRoleSchema, AssetOrder, AssetOrderSchema } from 'src/enum';
|
||||
import { MaybeDehydrated } from 'src/types';
|
||||
import { asDateString } from 'src/utils/date';
|
||||
import { asDateTimeString } from 'src/utils/date';
|
||||
import { stringToBool } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
@@ -195,14 +195,14 @@ export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto
|
||||
albumName: entity.albumName,
|
||||
description: entity.description,
|
||||
albumThumbnailAssetId: entity.albumThumbnailAssetId,
|
||||
createdAt: asDateString(entity.createdAt),
|
||||
updatedAt: asDateString(entity.updatedAt),
|
||||
createdAt: asDateTimeString(entity.createdAt),
|
||||
updatedAt: asDateTimeString(entity.updatedAt),
|
||||
id: entity.id,
|
||||
albumUsers,
|
||||
shared: hasSharedUser || hasSharedLink,
|
||||
hasSharedLink,
|
||||
startDate: asDateString(startDate),
|
||||
endDate: asDateString(endDate),
|
||||
startDate: asDateTimeString(startDate),
|
||||
endDate: asDateTimeString(endDate),
|
||||
assetCount: entity.assets?.length || 0,
|
||||
isActivityEnabled: entity.isActivityEnabled,
|
||||
order: entity.order,
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from 'src/enum';
|
||||
import { MaybeDehydrated } from 'src/types';
|
||||
import { hexOrBufferToBase64 } from 'src/utils/bytes';
|
||||
import { asDateString } from 'src/utils/date';
|
||||
import { asDateTimeString } from 'src/utils/date';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import z from 'zod';
|
||||
|
||||
@@ -199,7 +199,7 @@ export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOpt
|
||||
type: entity.type,
|
||||
originalMimeType: mimeTypes.lookup(entity.originalFileName),
|
||||
thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null,
|
||||
localDateTime: asDateString(entity.localDateTime),
|
||||
localDateTime: asDateTimeString(entity.localDateTime),
|
||||
duration: entity.duration,
|
||||
livePhotoVideoId: entity.livePhotoVideoId,
|
||||
hasMetadata: false,
|
||||
@@ -211,7 +211,7 @@ export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOpt
|
||||
|
||||
return {
|
||||
id: entity.id,
|
||||
createdAt: asDateString(entity.createdAt),
|
||||
createdAt: asDateTimeString(entity.createdAt),
|
||||
ownerId: entity.ownerId,
|
||||
owner: entity.owner ? mapUser(entity.owner) : undefined,
|
||||
libraryId: entity.libraryId,
|
||||
@@ -220,10 +220,10 @@ export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOpt
|
||||
originalFileName: entity.originalFileName,
|
||||
originalMimeType: mimeTypes.lookup(entity.originalFileName),
|
||||
thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null,
|
||||
fileCreatedAt: asDateString(entity.fileCreatedAt),
|
||||
fileModifiedAt: asDateString(entity.fileModifiedAt),
|
||||
localDateTime: asDateString(entity.localDateTime),
|
||||
updatedAt: asDateString(entity.updatedAt),
|
||||
fileCreatedAt: asDateTimeString(entity.fileCreatedAt),
|
||||
fileModifiedAt: asDateTimeString(entity.fileModifiedAt),
|
||||
localDateTime: asDateTimeString(entity.localDateTime),
|
||||
updatedAt: asDateTimeString(entity.updatedAt),
|
||||
isFavorite: options.auth?.user.id === entity.ownerId && entity.isFavorite,
|
||||
isArchived: entity.visibility === AssetVisibility.Archive,
|
||||
isTrashed: !!entity.deletedAt,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { Exif } from 'src/database';
|
||||
import { MaybeDehydrated } from 'src/types';
|
||||
import { asDateString } from 'src/utils/date';
|
||||
import { asDateTimeString } from 'src/utils/date';
|
||||
import z from 'zod';
|
||||
|
||||
export const ExifResponseSchema = z
|
||||
@@ -44,8 +44,8 @@ export function mapExif(entity: MaybeDehydrated<Exif>): ExifResponseDto {
|
||||
exifImageHeight: entity.exifImageHeight,
|
||||
fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null,
|
||||
orientation: entity.orientation,
|
||||
dateTimeOriginal: asDateString(entity.dateTimeOriginal),
|
||||
modifyDate: asDateString(entity.modifyDate),
|
||||
dateTimeOriginal: asDateTimeString(entity.dateTimeOriginal),
|
||||
modifyDate: asDateTimeString(entity.modifyDate),
|
||||
timeZone: entity.timeZone,
|
||||
lensModel: entity.lensModel,
|
||||
fNumber: entity.fNumber,
|
||||
|
||||
@@ -7,22 +7,24 @@ import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
||||
import { SourceTypeSchema } from 'src/enum';
|
||||
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||
import { ImageDimensions, MaybeDehydrated } from 'src/types';
|
||||
import { asBirthDateString, asDateString } from 'src/utils/date';
|
||||
import { asDateString, asDateTimeString } from 'src/utils/date';
|
||||
import { transformFaceBoundingBox } from 'src/utils/transform';
|
||||
import { emptyStringToNull, hexColor, stringToBool } from 'src/validation';
|
||||
import { hexColor, stringToBool } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
const PersonCreateSchema = z
|
||||
.object({
|
||||
name: z.string().optional().describe('Person name'),
|
||||
// Note: the mobile app cannot currently set the birth date to null.
|
||||
birthDate: emptyStringToNull(z.string().meta({ format: 'date' }).nullable())
|
||||
birthDate: z
|
||||
.string()
|
||||
.meta({ format: 'date' })
|
||||
.nullable()
|
||||
.optional()
|
||||
.refine((val) => (val ? new Date(val) <= new Date() : true), { error: 'Birth date cannot be in the future' })
|
||||
.describe('Person date of birth'),
|
||||
isHidden: z.boolean().optional().describe('Person visibility (hidden)'),
|
||||
isFavorite: z.boolean().optional().describe('Mark as favorite'),
|
||||
color: emptyStringToNull(hexColor.nullable()).optional().describe('Person color (hex)'),
|
||||
color: hexColor.nullable().optional().describe('Person color (hex)'),
|
||||
})
|
||||
.meta({ id: 'PersonCreateDto' });
|
||||
|
||||
@@ -173,12 +175,12 @@ export function mapPerson(person: MaybeDehydrated<Person>): PersonResponseDto {
|
||||
return {
|
||||
id: person.id,
|
||||
name: person.name,
|
||||
birthDate: asBirthDateString(person.birthDate),
|
||||
birthDate: asDateString(person.birthDate),
|
||||
thumbnailPath: person.thumbnailPath,
|
||||
isHidden: person.isHidden,
|
||||
isFavorite: person.isFavorite,
|
||||
color: person.color ?? undefined,
|
||||
updatedAt: asDateString(person.updatedAt),
|
||||
updatedAt: asDateTimeString(person.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { HistoryBuilder } from 'src/decorators';
|
||||
import { AlbumResponseSchema } from 'src/dtos/album.dto';
|
||||
import { AssetResponseSchema } from 'src/dtos/asset-response.dto';
|
||||
import { AssetOrder, AssetOrderSchema, AssetTypeSchema, AssetVisibilitySchema } from 'src/enum';
|
||||
import { emptyStringToNull, isoDatetimeToDate, stringToBool } from 'src/validation';
|
||||
import { isoDatetimeToDate, stringToBool } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
const BaseSearchSchema = z.object({
|
||||
@@ -23,12 +23,12 @@ const BaseSearchSchema = z.object({
|
||||
trashedAfter: isoDatetimeToDate.optional().describe('Filter by trash date (after)'),
|
||||
takenBefore: isoDatetimeToDate.optional().describe('Filter by taken date (before)'),
|
||||
takenAfter: isoDatetimeToDate.optional().describe('Filter by taken date (after)'),
|
||||
city: emptyStringToNull(z.string().nullable()).optional().describe('Filter by city name'),
|
||||
state: emptyStringToNull(z.string().nullable()).optional().describe('Filter by state/province name'),
|
||||
country: emptyStringToNull(z.string().nullable()).optional().describe('Filter by country name'),
|
||||
make: emptyStringToNull(z.string().nullable()).optional().describe('Filter by camera make'),
|
||||
model: emptyStringToNull(z.string().nullable()).optional().describe('Filter by camera model'),
|
||||
lensModel: emptyStringToNull(z.string().nullable()).optional().describe('Filter by lens model'),
|
||||
city: z.string().nullable().optional().describe('Filter by city name'),
|
||||
state: z.string().nullable().optional().describe('Filter by state/province name'),
|
||||
country: z.string().nullable().optional().describe('Filter by country name'),
|
||||
make: z.string().nullable().optional().describe('Filter by camera make'),
|
||||
model: z.string().nullable().optional().describe('Filter by camera model'),
|
||||
lensModel: z.string().nullable().optional().describe('Filter by lens model'),
|
||||
isNotInAlbum: z.boolean().optional().describe('Filter assets not in any album'),
|
||||
personIds: z.array(z.uuidv4()).optional().describe('Filter by person IDs'),
|
||||
tagIds: z.array(z.uuidv4()).nullish().describe('Filter by tag IDs'),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { HistoryBuilder } from 'src/decorators';
|
||||
import { AlbumResponseSchema, mapAlbum } from 'src/dtos/album.dto';
|
||||
import { AssetResponseSchema, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { SharedLinkTypeSchema } from 'src/enum';
|
||||
import { emptyStringToNull, isoDatetimeToDate } from 'src/validation';
|
||||
import { isoDatetimeToDate } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
const SharedLinkSearchSchema = z
|
||||
@@ -23,9 +23,9 @@ const SharedLinkCreateSchema = z
|
||||
type: SharedLinkTypeSchema,
|
||||
assetIds: z.array(z.uuidv4()).optional().describe('Asset IDs (for individual assets)'),
|
||||
albumId: z.uuidv4().optional().describe('Album ID (for album sharing)'),
|
||||
description: emptyStringToNull(z.string().nullable()).optional().describe('Link description'),
|
||||
password: emptyStringToNull(z.string().nullable()).optional().describe('Link password'),
|
||||
slug: emptyStringToNull(z.string().nullable()).optional().describe('Custom URL slug'),
|
||||
description: z.string().nullable().optional().describe('Link description'),
|
||||
password: z.string().nullable().optional().describe('Link password'),
|
||||
slug: z.string().nullable().optional().describe('Custom URL slug'),
|
||||
expiresAt: isoDatetimeToDate.nullable().describe('Expiration date').default(null).optional(),
|
||||
allowUpload: z.boolean().optional().describe('Allow uploads'),
|
||||
allowDownload: z.boolean().default(true).optional().describe('Allow downloads'),
|
||||
@@ -35,19 +35,13 @@ const SharedLinkCreateSchema = z
|
||||
|
||||
const SharedLinkEditSchema = z
|
||||
.object({
|
||||
description: emptyStringToNull(z.string().nullable()).optional().describe('Link description'),
|
||||
password: emptyStringToNull(z.string().nullable()).optional().describe('Link password'),
|
||||
slug: emptyStringToNull(z.string().nullable()).optional().describe('Custom URL slug'),
|
||||
description: z.string().nullable().optional().describe('Link description'),
|
||||
password: z.string().nullable().optional().describe('Link password'),
|
||||
slug: z.string().nullable().optional().describe('Custom URL slug'),
|
||||
expiresAt: isoDatetimeToDate.nullish().describe('Expiration date'),
|
||||
allowUpload: z.boolean().optional().describe('Allow uploads'),
|
||||
allowDownload: z.boolean().optional().describe('Allow downloads'),
|
||||
showMetadata: z.boolean().optional().describe('Show metadata'),
|
||||
changeExpiryTime: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'Whether to change the expiry time. Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this.',
|
||||
),
|
||||
})
|
||||
.meta({ id: 'SharedLinkEditDto' });
|
||||
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { Tag } from 'src/database';
|
||||
import { MaybeDehydrated } from 'src/types';
|
||||
import { asDateString } from 'src/utils/date';
|
||||
import { emptyStringToNull, hexColor } from 'src/validation';
|
||||
import { asDateTimeString } from 'src/utils/date';
|
||||
import { hexColor } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
const TagCreateSchema = z
|
||||
.object({
|
||||
name: z.string().describe('Tag name'),
|
||||
parentId: z.uuidv4().nullish().describe('Parent tag ID'),
|
||||
color: emptyStringToNull(hexColor.nullable()).optional().describe('Tag color (hex)'),
|
||||
color: hexColor.nullable().optional().describe('Tag color (hex)'),
|
||||
})
|
||||
.meta({ id: 'TagCreateDto' });
|
||||
|
||||
const TagUpdateSchema = z
|
||||
.object({
|
||||
color: emptyStringToNull(hexColor.nullable()).optional().describe('Tag color (hex)'),
|
||||
color: hexColor.nullable().optional().describe('Tag color (hex)'),
|
||||
})
|
||||
.meta({ id: 'TagUpdateDto' });
|
||||
|
||||
@@ -65,8 +65,8 @@ export function mapTag(entity: MaybeDehydrated<Tag>): TagResponseDto {
|
||||
parentId: entity.parentId ?? undefined,
|
||||
name: entity.value.split('/').at(-1) as string,
|
||||
value: entity.value,
|
||||
createdAt: asDateString(entity.createdAt),
|
||||
updatedAt: asDateString(entity.updatedAt),
|
||||
createdAt: asDateTimeString(entity.createdAt),
|
||||
updatedAt: asDateTimeString(entity.updatedAt),
|
||||
color: entity.color ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { User, UserAdmin } from 'src/database';
|
||||
import { pinCodeRegex } from 'src/dtos/auth.dto';
|
||||
import { UserAvatarColor, UserAvatarColorSchema, UserMetadataKey, UserStatusSchema } from 'src/enum';
|
||||
import { MaybeDehydrated, UserMetadataItem } from 'src/types';
|
||||
import { asDateString } from 'src/utils/date';
|
||||
import { emptyStringToNull, isoDatetimeToDate, sanitizeFilename, stringToBool, toEmail } from 'src/validation';
|
||||
import { asDateTimeString } from 'src/utils/date';
|
||||
import { isoDatetimeToDate, sanitizeFilename, stringToBool, toEmail } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
export const UserUpdateMeSchema = z
|
||||
@@ -61,7 +61,7 @@ export const mapUser = (entity: MaybeDehydrated<User | UserAdmin>): UserResponse
|
||||
name: entity.name,
|
||||
profileImagePath: entity.profileImagePath,
|
||||
avatarColor: entity.avatarColor ?? emailToAvatarColor(entity.email),
|
||||
profileChangedAt: asDateString(entity.profileChangedAt),
|
||||
profileChangedAt: asDateTimeString(entity.profileChangedAt),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -80,10 +80,7 @@ export const UserAdminCreateSchema = z
|
||||
password: z.string().describe('User password'),
|
||||
name: z.string().describe('User name'),
|
||||
avatarColor: UserAvatarColorSchema.nullish(),
|
||||
pinCode: emptyStringToNull(z.string().regex(pinCodeRegex).nullable())
|
||||
.optional()
|
||||
.describe('PIN code')
|
||||
.meta({ example: '123456' }),
|
||||
pinCode: z.string().regex(pinCodeRegex).nullable().optional().describe('PIN code').meta({ example: '123456' }),
|
||||
storageLabel: z.string().pipe(sanitizeFilename).nullish().describe('Storage label'),
|
||||
quotaSizeInBytes: z.int().min(0).nullish().describe('Storage quota in bytes'),
|
||||
shouldChangePassword: z.boolean().optional().describe('Require password change on next login'),
|
||||
@@ -98,10 +95,7 @@ const UserAdminUpdateSchema = z
|
||||
.object({
|
||||
email: toEmail.optional().describe('User email'),
|
||||
password: z.string().optional().describe('User password'),
|
||||
pinCode: emptyStringToNull(z.string().regex(pinCodeRegex).nullable())
|
||||
.optional()
|
||||
.describe('PIN code')
|
||||
.meta({ example: '123456' }),
|
||||
pinCode: z.string().regex(pinCodeRegex).nullable().optional().describe('PIN code').meta({ example: '123456' }),
|
||||
name: z.string().optional().describe('User name'),
|
||||
avatarColor: UserAvatarColorSchema.nullish(),
|
||||
storageLabel: z.string().pipe(sanitizeFilename).nullish().describe('Storage label'),
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
HttpException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { Observable, catchError, throwError } from 'rxjs';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { logGlobalError } from 'src/utils/logger';
|
||||
import { isHttpException, onRequestError } from 'src/utils/logger';
|
||||
import { routeToErrorMessage } from 'src/utils/misc';
|
||||
|
||||
@Injectable()
|
||||
@@ -18,14 +18,16 @@ export class ErrorInterceptor implements NestInterceptor {
|
||||
}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> {
|
||||
const req = context.switchToHttp().getRequest<Request>();
|
||||
|
||||
return next.handle().pipe(
|
||||
catchError((error) =>
|
||||
throwError(() => {
|
||||
if (error instanceof HttpException) {
|
||||
if (isHttpException(error)) {
|
||||
return error;
|
||||
}
|
||||
|
||||
logGlobalError(this.logger, error);
|
||||
onRequestError(req, error, this.logger);
|
||||
|
||||
const message = routeToErrorMessage(context.getHandler().name);
|
||||
return new InternalServerErrorException(message);
|
||||
|
||||
@@ -96,7 +96,11 @@ export class FileUploadInterceptor implements NestInterceptor {
|
||||
|
||||
private handleFile(request: AuthRequest, file: Express.Multer.File, callback: Callback<Partial<ImmichFile>>) {
|
||||
request.on('error', (error) => {
|
||||
this.logger.warn('Request error while uploading file, cleaning up', error);
|
||||
if ('code' in error && error.code === 'ECONNRESET') {
|
||||
this.logger.debug('Upload was cancelled');
|
||||
} else {
|
||||
this.logger.error(`Upload failed with: ${error}`);
|
||||
}
|
||||
this.assetService.onUploadError(request, file).catch(this.logger.error);
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user