mirror of
https://github.com/immich-app/immich.git
synced 2026-05-22 07:32:32 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49a02ab2d9 |
@@ -288,6 +288,7 @@ jobs:
|
|||||||
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||||
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
||||||
ENVIRONMENT: ${{ inputs.environment || 'development' }}
|
ENVIRONMENT: ${{ inputs.environment || 'development' }}
|
||||||
|
BUNDLE_ID_SUFFIX: ${{ inputs.environment == 'production' && '' || 'development' }}
|
||||||
GITHUB_REF: ${{ github.ref }}
|
GITHUB_REF: ${{ github.ref }}
|
||||||
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 120
|
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 120
|
||||||
FASTLANE_XCODEBUILD_SETTINGS_RETRIES: 6
|
FASTLANE_XCODEBUILD_SETTINGS_RETRIES: 6
|
||||||
|
|||||||
+1
-1
@@ -28,4 +28,4 @@ run = "prettier --write ."
|
|||||||
run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}"
|
run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}"
|
||||||
|
|
||||||
[tools]
|
[tools]
|
||||||
wrangler = "4.91.0"
|
wrangler = "4.66.0"
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
|
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
|
||||||
|
|
||||||
[[tools."aqua:flutter/flutter"]]
|
|
||||||
version = "3.41.9"
|
|
||||||
backend = "aqua:flutter/flutter"
|
|
||||||
|
|
||||||
[[tools.flutter]]
|
[[tools.flutter]]
|
||||||
version = "3.41.9-stable"
|
version = "3.41.9-stable"
|
||||||
backend = "asdf:flutter"
|
backend = "asdf:flutter"
|
||||||
@@ -16,36 +12,43 @@ backend = "github:CQLabs/homebrew-dcm"
|
|||||||
checksum = "sha256:253da2512b149913dfe345bf9a62a79acb2d730f66e71162ba4a92dfc4224b82"
|
checksum = "sha256:253da2512b149913dfe345bf9a62a79acb2d730f66e71162ba4a92dfc4224b82"
|
||||||
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-arm-release.zip"
|
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"
|
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543838"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-arm64-musl"]
|
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-arm64-musl"]
|
||||||
checksum = "sha256:253da2512b149913dfe345bf9a62a79acb2d730f66e71162ba4a92dfc4224b82"
|
checksum = "sha256:253da2512b149913dfe345bf9a62a79acb2d730f66e71162ba4a92dfc4224b82"
|
||||||
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-arm-release.zip"
|
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"
|
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543838"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64"]
|
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64"]
|
||||||
checksum = "sha256:477e086d4099c12f21e5ccd83b005d5fb945dd4cac4fd127fd9a08d7649af1cf"
|
checksum = "sha256:477e086d4099c12f21e5ccd83b005d5fb945dd4cac4fd127fd9a08d7649af1cf"
|
||||||
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-x64-release.zip"
|
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"
|
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543797"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64-musl"]
|
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64-musl"]
|
||||||
checksum = "sha256:477e086d4099c12f21e5ccd83b005d5fb945dd4cac4fd127fd9a08d7649af1cf"
|
checksum = "sha256:477e086d4099c12f21e5ccd83b005d5fb945dd4cac4fd127fd9a08d7649af1cf"
|
||||||
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-x64-release.zip"
|
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"
|
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543797"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:CQLabs/homebrew-dcm"."platforms.macos-arm64"]
|
[tools."github:CQLabs/homebrew-dcm"."platforms.macos-arm64"]
|
||||||
checksum = "sha256:30bede64367d09067093cc57af6ec9496d7717898138ded5cb98a16ac8dd9d93"
|
checksum = "sha256:30bede64367d09067093cc57af6ec9496d7717898138ded5cb98a16ac8dd9d93"
|
||||||
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-macos-arm-release.zip"
|
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"
|
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543757"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:CQLabs/homebrew-dcm"."platforms.macos-x64"]
|
[tools."github:CQLabs/homebrew-dcm"."platforms.macos-x64"]
|
||||||
checksum = "sha256:e56cb99872be7445a4de1d37e5438ca70e3bcd83be7a2b9b385e3538881f8068"
|
checksum = "sha256:e56cb99872be7445a4de1d37e5438ca70e3bcd83be7a2b9b385e3538881f8068"
|
||||||
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-macos-x64-release.zip"
|
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"
|
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543727"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:CQLabs/homebrew-dcm"."platforms.windows-x64"]
|
[tools."github:CQLabs/homebrew-dcm"."platforms.windows-x64"]
|
||||||
checksum = "sha256:f133470daa3fb0427f039b424392af7e917d7e7db6b556aa2a968ab0e31587da"
|
checksum = "sha256:f133470daa3fb0427f039b424392af7e917d7e7db6b556aa2a968ab0e31587da"
|
||||||
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-windows-release.zip"
|
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"
|
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543660"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[[tools."github:extism/cli"]]
|
[[tools."github:extism/cli"]]
|
||||||
version = "1.6.3"
|
version = "1.6.3"
|
||||||
@@ -55,36 +58,43 @@ backend = "github:extism/cli"
|
|||||||
checksum = "sha256:d92f830c9be39637569feacb04e9750c28848df6d9a219db94152a9b4eb9452b"
|
checksum = "sha256:d92f830c9be39637569feacb04e9750c28848df6d9a219db94152a9b4eb9452b"
|
||||||
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-arm64.tar.gz"
|
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-arm64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694030"
|
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694030"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:extism/cli"."platforms.linux-arm64-musl"]
|
[tools."github:extism/cli"."platforms.linux-arm64-musl"]
|
||||||
checksum = "sha256:d92f830c9be39637569feacb04e9750c28848df6d9a219db94152a9b4eb9452b"
|
checksum = "sha256:d92f830c9be39637569feacb04e9750c28848df6d9a219db94152a9b4eb9452b"
|
||||||
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-arm64.tar.gz"
|
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-arm64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694030"
|
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694030"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:extism/cli"."platforms.linux-x64"]
|
[tools."github:extism/cli"."platforms.linux-x64"]
|
||||||
checksum = "sha256:34e7ae9bfded6e2c32dee83f70a4e50d34f9d3e80d1762b09625fe82e214d02d"
|
checksum = "sha256:34e7ae9bfded6e2c32dee83f70a4e50d34f9d3e80d1762b09625fe82e214d02d"
|
||||||
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-amd64.tar.gz"
|
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-amd64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694025"
|
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694025"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:extism/cli"."platforms.linux-x64-musl"]
|
[tools."github:extism/cli"."platforms.linux-x64-musl"]
|
||||||
checksum = "sha256:34e7ae9bfded6e2c32dee83f70a4e50d34f9d3e80d1762b09625fe82e214d02d"
|
checksum = "sha256:34e7ae9bfded6e2c32dee83f70a4e50d34f9d3e80d1762b09625fe82e214d02d"
|
||||||
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-amd64.tar.gz"
|
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-amd64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694025"
|
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694025"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:extism/cli"."platforms.macos-arm64"]
|
[tools."github:extism/cli"."platforms.macos-arm64"]
|
||||||
checksum = "sha256:b4ddbc575b5ac000115247f781723f9b9f284ed87b29c600539d72161b5b29fc"
|
checksum = "sha256:b4ddbc575b5ac000115247f781723f9b9f284ed87b29c600539d72161b5b29fc"
|
||||||
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-darwin-arm64.tar.gz"
|
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-darwin-arm64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694029"
|
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694029"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:extism/cli"."platforms.macos-x64"]
|
[tools."github:extism/cli"."platforms.macos-x64"]
|
||||||
checksum = "sha256:9a2f71b6e6009685a622cc3084e52d2a1a8e23c98d29ffa72e666e9dc699855f"
|
checksum = "sha256:9a2f71b6e6009685a622cc3084e52d2a1a8e23c98d29ffa72e666e9dc699855f"
|
||||||
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-darwin-amd64.tar.gz"
|
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-darwin-amd64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694026"
|
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694026"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:extism/cli"."platforms.windows-x64"]
|
[tools."github:extism/cli"."platforms.windows-x64"]
|
||||||
checksum = "sha256:47e4ed2782445b2b08a4d1ac127211588f8b4d1fc25fd6481d4cb65151b5213c"
|
checksum = "sha256:47e4ed2782445b2b08a4d1ac127211588f8b4d1fc25fd6481d4cb65151b5213c"
|
||||||
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-windows-amd64.zip"
|
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-windows-amd64.zip"
|
||||||
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694035"
|
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694035"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[[tools."github:extism/js-pdk"]]
|
[[tools."github:extism/js-pdk"]]
|
||||||
version = "1.6.0"
|
version = "1.6.0"
|
||||||
@@ -94,36 +104,43 @@ backend = "github:extism/js-pdk"
|
|||||||
checksum = "sha256:15a186250e68d6bff4ec839fff275d45a90e383a69209dcc1239eb9e3aee6e1b"
|
checksum = "sha256:15a186250e68d6bff4ec839fff275d45a90e383a69209dcc1239eb9e3aee6e1b"
|
||||||
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-aarch64-linux-v1.6.0.gz"
|
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-aarch64-linux-v1.6.0.gz"
|
||||||
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223214"
|
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223214"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:extism/js-pdk"."platforms.linux-arm64-musl"]
|
[tools."github:extism/js-pdk"."platforms.linux-arm64-musl"]
|
||||||
checksum = "sha256:15a186250e68d6bff4ec839fff275d45a90e383a69209dcc1239eb9e3aee6e1b"
|
checksum = "sha256:15a186250e68d6bff4ec839fff275d45a90e383a69209dcc1239eb9e3aee6e1b"
|
||||||
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-aarch64-linux-v1.6.0.gz"
|
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-aarch64-linux-v1.6.0.gz"
|
||||||
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223214"
|
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223214"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:extism/js-pdk"."platforms.linux-x64"]
|
[tools."github:extism/js-pdk"."platforms.linux-x64"]
|
||||||
checksum = "sha256:4ded271ccf465031ccd0dc35e7a140e134d7f30721671cc4a8e1ff805d4aad68"
|
checksum = "sha256:4ded271ccf465031ccd0dc35e7a140e134d7f30721671cc4a8e1ff805d4aad68"
|
||||||
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-x86_64-linux-v1.6.0.gz"
|
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-x86_64-linux-v1.6.0.gz"
|
||||||
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223119"
|
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223119"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:extism/js-pdk"."platforms.linux-x64-musl"]
|
[tools."github:extism/js-pdk"."platforms.linux-x64-musl"]
|
||||||
checksum = "sha256:4ded271ccf465031ccd0dc35e7a140e134d7f30721671cc4a8e1ff805d4aad68"
|
checksum = "sha256:4ded271ccf465031ccd0dc35e7a140e134d7f30721671cc4a8e1ff805d4aad68"
|
||||||
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-x86_64-linux-v1.6.0.gz"
|
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-x86_64-linux-v1.6.0.gz"
|
||||||
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223119"
|
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223119"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:extism/js-pdk"."platforms.macos-arm64"]
|
[tools."github:extism/js-pdk"."platforms.macos-arm64"]
|
||||||
checksum = "sha256:548e25bda3971a07c32d78a249135cf8cb7b3eede101e878e06e53e01ac2e0ce"
|
checksum = "sha256:548e25bda3971a07c32d78a249135cf8cb7b3eede101e878e06e53e01ac2e0ce"
|
||||||
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-aarch64-macos-v1.6.0.gz"
|
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-aarch64-macos-v1.6.0.gz"
|
||||||
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223215"
|
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223215"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:extism/js-pdk"."platforms.macos-x64"]
|
[tools."github:extism/js-pdk"."platforms.macos-x64"]
|
||||||
checksum = "sha256:d85a875c2a071f0c29fe572764c52c3a499f157ab7f9efac8939a4364390e29b"
|
checksum = "sha256:d85a875c2a071f0c29fe572764c52c3a499f157ab7f9efac8939a4364390e29b"
|
||||||
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-x86_64-macos-v1.6.0.gz"
|
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-x86_64-macos-v1.6.0.gz"
|
||||||
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223239"
|
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223239"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:extism/js-pdk"."platforms.windows-x64"]
|
[tools."github:extism/js-pdk"."platforms.windows-x64"]
|
||||||
checksum = "sha256:97b7b746141e4777e1ca2b76febdeb16dc9d314ff6a4257df05a476b67228acc"
|
checksum = "sha256:97b7b746141e4777e1ca2b76febdeb16dc9d314ff6a4257df05a476b67228acc"
|
||||||
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-x86_64-windows-v1.6.0.gz"
|
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-x86_64-windows-v1.6.0.gz"
|
||||||
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353224133"
|
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353224133"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
|
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
|
||||||
version = "7.1.3-6"
|
version = "7.1.3-6"
|
||||||
@@ -133,36 +150,43 @@ backend = "github:jellyfin/jellyfin-ffmpeg"
|
|||||||
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
|
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
|
||||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
|
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
|
||||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
|
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"]
|
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"]
|
||||||
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
|
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
|
||||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
|
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
|
||||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
|
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64"]
|
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64"]
|
||||||
checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2"
|
checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2"
|
||||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz"
|
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz"
|
||||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879"
|
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64-musl"]
|
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64-musl"]
|
||||||
checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2"
|
checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2"
|
||||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz"
|
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz"
|
||||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879"
|
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-arm64"]
|
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-arm64"]
|
||||||
checksum = "sha256:e024d5e78d5414e75f0181036cd21373fafb9270c72894dfd7dbda2572439820"
|
checksum = "sha256:e024d5e78d5414e75f0181036cd21373fafb9270c72894dfd7dbda2572439820"
|
||||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_macarm64-gpl.tar.xz"
|
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_macarm64-gpl.tar.xz"
|
||||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995838"
|
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995838"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-x64"]
|
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-x64"]
|
||||||
checksum = "sha256:066ede9774aaae97a18098aaeea8b7e0d286653eb8618f640476e99c59a536c2"
|
checksum = "sha256:066ede9774aaae97a18098aaeea8b7e0d286653eb8618f640476e99c59a536c2"
|
||||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_mac64-gpl.tar.xz"
|
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_mac64-gpl.tar.xz"
|
||||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995889"
|
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995889"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.windows-x64"]
|
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.windows-x64"]
|
||||||
checksum = "sha256:7b7168149689610296f3a187c717056ce0786cc125a31caf28056737e9ba1cc1"
|
checksum = "sha256:7b7168149689610296f3a187c717056ce0786cc125a31caf28056737e9ba1cc1"
|
||||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_win64-clang-gpl.zip"
|
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_win64-clang-gpl.zip"
|
||||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409036094"
|
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409036094"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[[tools."github:webassembly/binaryen"]]
|
[[tools."github:webassembly/binaryen"]]
|
||||||
version = "version_124"
|
version = "version_124"
|
||||||
@@ -172,36 +196,43 @@ backend = "github:webassembly/binaryen"
|
|||||||
checksum = "sha256:6291bd9a57d8e046f3bc099a4db386c147433a87f71c783a901c5b1792e38de3"
|
checksum = "sha256:6291bd9a57d8e046f3bc099a4db386c147433a87f71c783a901c5b1792e38de3"
|
||||||
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-aarch64-linux.tar.gz"
|
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-aarch64-linux.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288927659"
|
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288927659"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:webassembly/binaryen"."platforms.linux-arm64-musl"]
|
[tools."github:webassembly/binaryen"."platforms.linux-arm64-musl"]
|
||||||
checksum = "sha256:6291bd9a57d8e046f3bc099a4db386c147433a87f71c783a901c5b1792e38de3"
|
checksum = "sha256:6291bd9a57d8e046f3bc099a4db386c147433a87f71c783a901c5b1792e38de3"
|
||||||
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-aarch64-linux.tar.gz"
|
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-aarch64-linux.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288927659"
|
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288927659"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:webassembly/binaryen"."platforms.linux-x64"]
|
[tools."github:webassembly/binaryen"."platforms.linux-x64"]
|
||||||
checksum = "sha256:0290c3779fedf592b8da0ded3032ff55c41a2b7bfa2d6bf7b7bac6f0e6e28963"
|
checksum = "sha256:0290c3779fedf592b8da0ded3032ff55c41a2b7bfa2d6bf7b7bac6f0e6e28963"
|
||||||
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-linux.tar.gz"
|
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-linux.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926769"
|
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926769"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:webassembly/binaryen"."platforms.linux-x64-musl"]
|
[tools."github:webassembly/binaryen"."platforms.linux-x64-musl"]
|
||||||
checksum = "sha256:0290c3779fedf592b8da0ded3032ff55c41a2b7bfa2d6bf7b7bac6f0e6e28963"
|
checksum = "sha256:0290c3779fedf592b8da0ded3032ff55c41a2b7bfa2d6bf7b7bac6f0e6e28963"
|
||||||
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-linux.tar.gz"
|
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-linux.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926769"
|
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926769"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:webassembly/binaryen"."platforms.macos-arm64"]
|
[tools."github:webassembly/binaryen"."platforms.macos-arm64"]
|
||||||
checksum = "sha256:86a2c960ff62c6d2ea6009d1f89745c22c70100d394a095eab45eb941bdaa24c"
|
checksum = "sha256:86a2c960ff62c6d2ea6009d1f89745c22c70100d394a095eab45eb941bdaa24c"
|
||||||
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-arm64-macos.tar.gz"
|
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-arm64-macos.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926134"
|
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926134"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:webassembly/binaryen"."platforms.macos-x64"]
|
[tools."github:webassembly/binaryen"."platforms.macos-x64"]
|
||||||
checksum = "sha256:b389bb0731758d86c3cb266d01d28a12725c23bd3cabc3df34faa162af0887e9"
|
checksum = "sha256:b389bb0731758d86c3cb266d01d28a12725c23bd3cabc3df34faa162af0887e9"
|
||||||
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-macos.tar.gz"
|
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-macos.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926135"
|
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926135"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[tools."github:webassembly/binaryen"."platforms.windows-x64"]
|
[tools."github:webassembly/binaryen"."platforms.windows-x64"]
|
||||||
checksum = "sha256:b5e1d2a1ad3c03229ddc89823848f4a1c11f9c6402a51fa26f0aaa5f1d7a2203"
|
checksum = "sha256:b5e1d2a1ad3c03229ddc89823848f4a1c11f9c6402a51fa26f0aaa5f1d7a2203"
|
||||||
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-windows.tar.gz"
|
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"
|
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288925833"
|
||||||
|
github_attestations = "unavailable"
|
||||||
|
|
||||||
[[tools.java]]
|
[[tools.java]]
|
||||||
version = "21.0.2"
|
version = "21.0.2"
|
||||||
@@ -296,9 +327,37 @@ checksum = "sha256:27323f70c875b8251bfd7e61a4cffc3ebff4e56ed1e611b955016f0c70773
|
|||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_windows_amd64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_windows_amd64.tar.gz"
|
||||||
|
|
||||||
[[tools.pnpm]]
|
[[tools.pnpm]]
|
||||||
version = "10.33.4"
|
version = "10.33.1"
|
||||||
backend = "aqua:pnpm/pnpm"
|
backend = "aqua:pnpm/pnpm"
|
||||||
|
|
||||||
|
[tools.pnpm."platforms.linux-arm64"]
|
||||||
|
checksum = "sha256:ed8aa7901cf325f4cf5019405bdd6bf988426e4b23d08fe9b12ea4df7046f23e"
|
||||||
|
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.1/pnpm-linux-arm64"
|
||||||
|
|
||||||
|
[tools.pnpm."platforms.linux-arm64-musl"]
|
||||||
|
checksum = "sha256:ed8aa7901cf325f4cf5019405bdd6bf988426e4b23d08fe9b12ea4df7046f23e"
|
||||||
|
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.1/pnpm-linux-arm64"
|
||||||
|
|
||||||
|
[tools.pnpm."platforms.linux-x64"]
|
||||||
|
checksum = "sha256:fba950842532edd365e949b74643b64e6311089a45532dbe1e8f909a247fe3e9"
|
||||||
|
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.1/pnpm-linux-x64"
|
||||||
|
|
||||||
|
[tools.pnpm."platforms.linux-x64-musl"]
|
||||||
|
checksum = "sha256:fba950842532edd365e949b74643b64e6311089a45532dbe1e8f909a247fe3e9"
|
||||||
|
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.1/pnpm-linux-x64"
|
||||||
|
|
||||||
|
[tools.pnpm."platforms.macos-arm64"]
|
||||||
|
checksum = "sha256:909ced0038b00881d4d620ba2018c5d9691de373deea8e3c84b722b44324e47c"
|
||||||
|
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.1/pnpm-macos-arm64"
|
||||||
|
|
||||||
|
[tools.pnpm."platforms.macos-x64"]
|
||||||
|
checksum = "sha256:afdad60b83f4f482f4c95cc79325f29aef776d0922a324f023a312f40e0cc7d3"
|
||||||
|
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.1/pnpm-macos-x64"
|
||||||
|
|
||||||
|
[tools.pnpm."platforms.windows-x64"]
|
||||||
|
checksum = "sha256:67b23fd8c6800566b1cc04c446b170ff6e7977250084e4d8df9bfdbd8e6f4d02"
|
||||||
|
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.1/pnpm-win-x64.exe"
|
||||||
|
|
||||||
[[tools.terragrunt]]
|
[[tools.terragrunt]]
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
backend = "aqua:gruntwork-io/terragrunt"
|
backend = "aqua:gruntwork-io/terragrunt"
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ config_roots = [
|
|||||||
[tools]
|
[tools]
|
||||||
node = "24.15.0"
|
node = "24.15.0"
|
||||||
"aqua:flutter/flutter" = "3.41.9"
|
"aqua:flutter/flutter" = "3.41.9"
|
||||||
pnpm = "10.33.4"
|
pnpm = "10.33.1"
|
||||||
terragrunt = "1.0.3"
|
terragrunt = "1.0.3"
|
||||||
opentofu = "1.11.6"
|
opentofu = "1.11.6"
|
||||||
java = "21.0.2"
|
java = "21.0.2"
|
||||||
|
|||||||
@@ -315,7 +315,6 @@ interface NetworkApi {
|
|||||||
fun hasCertificate(): Boolean
|
fun hasCertificate(): Boolean
|
||||||
fun getClientPointer(): Long
|
fun getClientPointer(): Long
|
||||||
fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>, token: String?)
|
fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>, token: String?)
|
||||||
fun getAppGroupId(): String
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The codec used by NetworkApi. */
|
/** The codec used by NetworkApi. */
|
||||||
@@ -431,21 +430,6 @@ interface NetworkApi {
|
|||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
run {
|
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.getAppGroupId$separatedMessageChannelSuffix", codec)
|
|
||||||
if (api != null) {
|
|
||||||
channel.setMessageHandler { _, reply ->
|
|
||||||
val wrapped: List<Any?> = try {
|
|
||||||
listOf(api.getAppGroupId())
|
|
||||||
} catch (exception: Throwable) {
|
|
||||||
NetworkPigeonUtils.wrapError(exception)
|
|
||||||
}
|
|
||||||
reply.reply(wrapped)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
channel.setMessageHandler(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware {
|
|||||||
private var networkApi: NetworkApiImpl? = null
|
private var networkApi: NetworkApiImpl? = null
|
||||||
|
|
||||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
networkApi = NetworkApiImpl(binding.applicationContext)
|
networkApi = NetworkApiImpl()
|
||||||
NetworkApi.setUp(binding.binaryMessenger, networkApi)
|
NetworkApi.setUp(binding.binaryMessenger, networkApi)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,11 +39,9 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class NetworkApiImpl(private val context: Context) : NetworkApi {
|
private class NetworkApiImpl : NetworkApi {
|
||||||
var activity: Activity? = null
|
var activity: Activity? = null
|
||||||
|
|
||||||
override fun getAppGroupId(): String = context.packageName
|
|
||||||
|
|
||||||
override fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit) {
|
override fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit) {
|
||||||
try {
|
try {
|
||||||
HttpClientManager.setKeyEntry(clientData.data, clientData.password.toCharArray())
|
HttpClientManager.setKeyEntry(clientData.data, clientData.password.toCharArray())
|
||||||
|
|||||||
+131
-11
@@ -207,6 +207,18 @@ enum class PlatformAssetPlaybackStyle(val raw: Int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class EditState(val raw: Int) {
|
||||||
|
NOT_EDITED(0),
|
||||||
|
EDITED(1),
|
||||||
|
UNKNOWN(2);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun ofRaw(raw: Int): EditState? {
|
||||||
|
return values().firstOrNull { it.raw == raw }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Generated class from Pigeon that represents data sent in messages. */
|
/** Generated class from Pigeon that represents data sent in messages. */
|
||||||
data class PlatformAsset (
|
data class PlatformAsset (
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -472,6 +484,52 @@ data class CloudIdResult (
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Generated class from Pigeon that represents data sent in messages. */
|
||||||
|
data class BaseResource (
|
||||||
|
val path: String,
|
||||||
|
val sha1: String,
|
||||||
|
val sizeBytes: Long,
|
||||||
|
val mimeType: String
|
||||||
|
)
|
||||||
|
{
|
||||||
|
companion object {
|
||||||
|
fun fromList(pigeonVar_list: List<Any?>): BaseResource {
|
||||||
|
val path = pigeonVar_list[0] as String
|
||||||
|
val sha1 = pigeonVar_list[1] as String
|
||||||
|
val sizeBytes = pigeonVar_list[2] as Long
|
||||||
|
val mimeType = pigeonVar_list[3] as String
|
||||||
|
return BaseResource(path, sha1, sizeBytes, mimeType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun toList(): List<Any?> {
|
||||||
|
return listOf(
|
||||||
|
path,
|
||||||
|
sha1,
|
||||||
|
sizeBytes,
|
||||||
|
mimeType,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (other == null || other.javaClass != javaClass) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (this === other) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
val other = other as BaseResource
|
||||||
|
return MessagesPigeonUtils.deepEquals(this.path, other.path) && MessagesPigeonUtils.deepEquals(this.sha1, other.sha1) && MessagesPigeonUtils.deepEquals(this.sizeBytes, other.sizeBytes) && MessagesPigeonUtils.deepEquals(this.mimeType, other.mimeType)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = javaClass.hashCode()
|
||||||
|
result = 31 * result + MessagesPigeonUtils.deepHash(this.path)
|
||||||
|
result = 31 * result + MessagesPigeonUtils.deepHash(this.sha1)
|
||||||
|
result = 31 * result + MessagesPigeonUtils.deepHash(this.sizeBytes)
|
||||||
|
result = 31 * result + MessagesPigeonUtils.deepHash(this.mimeType)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
private open class MessagesPigeonCodec : StandardMessageCodec() {
|
private open class MessagesPigeonCodec : StandardMessageCodec() {
|
||||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||||
return when (type) {
|
return when (type) {
|
||||||
@@ -481,30 +539,40 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
130.toByte() -> {
|
130.toByte() -> {
|
||||||
return (readValue(buffer) as? List<Any?>)?.let {
|
return (readValue(buffer) as Long?)?.let {
|
||||||
PlatformAsset.fromList(it)
|
EditState.ofRaw(it.toInt())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
131.toByte() -> {
|
131.toByte() -> {
|
||||||
return (readValue(buffer) as? List<Any?>)?.let {
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
PlatformAlbum.fromList(it)
|
PlatformAsset.fromList(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
132.toByte() -> {
|
132.toByte() -> {
|
||||||
return (readValue(buffer) as? List<Any?>)?.let {
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
SyncDelta.fromList(it)
|
PlatformAlbum.fromList(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
133.toByte() -> {
|
133.toByte() -> {
|
||||||
return (readValue(buffer) as? List<Any?>)?.let {
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
HashResult.fromList(it)
|
SyncDelta.fromList(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
134.toByte() -> {
|
134.toByte() -> {
|
||||||
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
|
HashResult.fromList(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
135.toByte() -> {
|
||||||
return (readValue(buffer) as? List<Any?>)?.let {
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
CloudIdResult.fromList(it)
|
CloudIdResult.fromList(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
136.toByte() -> {
|
||||||
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
|
BaseResource.fromList(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
else -> super.readValueOfType(type, buffer)
|
else -> super.readValueOfType(type, buffer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -514,26 +582,34 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
|||||||
stream.write(129)
|
stream.write(129)
|
||||||
writeValue(stream, value.raw.toLong())
|
writeValue(stream, value.raw.toLong())
|
||||||
}
|
}
|
||||||
is PlatformAsset -> {
|
is EditState -> {
|
||||||
stream.write(130)
|
stream.write(130)
|
||||||
writeValue(stream, value.toList())
|
writeValue(stream, value.raw.toLong())
|
||||||
}
|
}
|
||||||
is PlatformAlbum -> {
|
is PlatformAsset -> {
|
||||||
stream.write(131)
|
stream.write(131)
|
||||||
writeValue(stream, value.toList())
|
writeValue(stream, value.toList())
|
||||||
}
|
}
|
||||||
is SyncDelta -> {
|
is PlatformAlbum -> {
|
||||||
stream.write(132)
|
stream.write(132)
|
||||||
writeValue(stream, value.toList())
|
writeValue(stream, value.toList())
|
||||||
}
|
}
|
||||||
is HashResult -> {
|
is SyncDelta -> {
|
||||||
stream.write(133)
|
stream.write(133)
|
||||||
writeValue(stream, value.toList())
|
writeValue(stream, value.toList())
|
||||||
}
|
}
|
||||||
is CloudIdResult -> {
|
is HashResult -> {
|
||||||
stream.write(134)
|
stream.write(134)
|
||||||
writeValue(stream, value.toList())
|
writeValue(stream, value.toList())
|
||||||
}
|
}
|
||||||
|
is CloudIdResult -> {
|
||||||
|
stream.write(135)
|
||||||
|
writeValue(stream, value.toList())
|
||||||
|
}
|
||||||
|
is BaseResource -> {
|
||||||
|
stream.write(136)
|
||||||
|
writeValue(stream, value.toList())
|
||||||
|
}
|
||||||
else -> super.writeValue(stream, value)
|
else -> super.writeValue(stream, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -554,6 +630,8 @@ interface NativeSyncApi {
|
|||||||
fun cancelHashing()
|
fun cancelHashing()
|
||||||
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
|
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
|
||||||
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
|
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
|
||||||
|
fun getBaseResource(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseResource?>) -> Unit)
|
||||||
|
fun getEditState(assetId: String, allowNetworkAccess: Boolean, callback: (Result<EditState>) -> Unit)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The codec used by NativeSyncApi. */
|
/** The codec used by NativeSyncApi. */
|
||||||
@@ -764,6 +842,48 @@ interface NativeSyncApi {
|
|||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource$separatedMessageChannelSuffix", codec, taskQueue)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { message, reply ->
|
||||||
|
val args = message as List<Any?>
|
||||||
|
val assetIdArg = args[0] as String
|
||||||
|
val allowNetworkAccessArg = args[1] as Boolean
|
||||||
|
api.getBaseResource(assetIdArg, allowNetworkAccessArg) { result: Result<BaseResource?> ->
|
||||||
|
val error = result.exceptionOrNull()
|
||||||
|
if (error != null) {
|
||||||
|
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||||
|
} else {
|
||||||
|
val data = result.getOrNull()
|
||||||
|
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState$separatedMessageChannelSuffix", codec, taskQueue)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { message, reply ->
|
||||||
|
val args = message as List<Any?>
|
||||||
|
val assetIdArg = args[0] as String
|
||||||
|
val allowNetworkAccessArg = args[1] as Boolean
|
||||||
|
api.getEditState(assetIdArg, allowNetworkAccessArg) { result: Result<EditState> ->
|
||||||
|
val error = result.exceptionOrNull()
|
||||||
|
if (error != null) {
|
||||||
|
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||||
|
} else {
|
||||||
|
val data = result.getOrNull()
|
||||||
|
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -453,4 +453,14 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
|
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Android has no Photos-style edit original to stack; iOS-only.
|
||||||
|
fun getBaseResource(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseResource?>) -> Unit) {
|
||||||
|
callback(Result.success(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
// iOS-only; Android assets never carry a Photos-style edit.
|
||||||
|
fun getEditState(assetId: String, allowNetworkAccess: Boolean, callback: (Result<EditState>) -> Unit) {
|
||||||
|
callback(Result.success(EditState.NOT_EDITED))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3378
File diff suppressed because it is too large
Load Diff
+3388
File diff suppressed because it is too large
Load Diff
@@ -718,7 +718,6 @@
|
|||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CUSTOM_GROUP_ID = group.app.immich.share.profile;
|
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
@@ -751,6 +750,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 240;
|
CURRENT_PROJECT_VERSION = 240;
|
||||||
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2W7AC6T8T5;
|
DEVELOPMENT_TEAM = 2W7AC6T8T5;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -801,7 +801,6 @@
|
|||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CUSTOM_GROUP_ID = group.app.immich.share.debug;
|
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
@@ -861,7 +860,6 @@
|
|||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
@@ -896,6 +894,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 240;
|
CURRENT_PROJECT_VERSION = 240;
|
||||||
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2W7AC6T8T5;
|
DEVELOPMENT_TEAM = 2W7AC6T8T5;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -925,6 +924,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 240;
|
CURRENT_PROJECT_VERSION = 240;
|
||||||
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2W7AC6T8T5;
|
DEVELOPMENT_TEAM = 2W7AC6T8T5;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -1080,6 +1080,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 240;
|
CURRENT_PROJECT_VERSION = 240;
|
||||||
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2W7AC6T8T5;
|
DEVELOPMENT_TEAM = 2W7AC6T8T5;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
@@ -1123,6 +1124,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 240;
|
CURRENT_PROJECT_VERSION = 240;
|
||||||
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2W7AC6T8T5;
|
DEVELOPMENT_TEAM = 2W7AC6T8T5;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
@@ -1163,6 +1165,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 240;
|
CURRENT_PROJECT_VERSION = 240;
|
||||||
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2W7AC6T8T5;
|
DEVELOPMENT_TEAM = 2W7AC6T8T5;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
|||||||
Generated
-14
@@ -288,7 +288,6 @@ protocol NetworkApi {
|
|||||||
func hasCertificate() throws -> Bool
|
func hasCertificate() throws -> Bool
|
||||||
func getClientPointer() throws -> Int64
|
func getClientPointer() throws -> Int64
|
||||||
func setRequestHeaders(headers: [String: String], serverUrls: [String], token: String?) throws
|
func setRequestHeaders(headers: [String: String], serverUrls: [String], token: String?) throws
|
||||||
func getAppGroupId() throws -> String
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||||
@@ -389,18 +388,5 @@ class NetworkApiSetup {
|
|||||||
} else {
|
} else {
|
||||||
setRequestHeadersChannel.setMessageHandler(nil)
|
setRequestHeadersChannel.setMessageHandler(nil)
|
||||||
}
|
}
|
||||||
let getAppGroupIdChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.getAppGroupId\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
|
||||||
if let api = api {
|
|
||||||
getAppGroupIdChannel.setMessageHandler { _, reply in
|
|
||||||
do {
|
|
||||||
let result = try api.getAppGroupId()
|
|
||||||
reply(wrapResult(result))
|
|
||||||
} catch {
|
|
||||||
reply(wrapError(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
getAppGroupIdChannel.setMessageHandler(nil)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,10 +61,6 @@ class NetworkApiImpl: NetworkApi {
|
|||||||
return Int64(Int(bitPattern: pointer))
|
return Int64(Int(bitPattern: pointer))
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAppGroupId() throws -> String {
|
|
||||||
return Bundle.main.object(forInfoDictionaryKey: "AppGroupId") as! String
|
|
||||||
}
|
|
||||||
|
|
||||||
func setRequestHeaders(headers: [String : String], serverUrls: [String], token: String?) throws {
|
func setRequestHeaders(headers: [String : String], serverUrls: [String], token: String?) throws {
|
||||||
URLSessionManager.setServerUrls(serverUrls)
|
URLSessionManager.setServerUrls(serverUrls)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import native_video_player
|
|||||||
let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity"
|
let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity"
|
||||||
let HEADERS_KEY = "immich.request_headers"
|
let HEADERS_KEY = "immich.request_headers"
|
||||||
let SERVER_URLS_KEY = "immich.server_urls"
|
let SERVER_URLS_KEY = "immich.server_urls"
|
||||||
let APP_GROUP = Bundle.main.object(forInfoDictionaryKey: "AppGroupId") as! String
|
let APP_GROUP = "group.app.immich.share"
|
||||||
let COOKIE_EXPIRY_DAYS: TimeInterval = 400
|
let COOKIE_EXPIRY_DAYS: TimeInterval = 400
|
||||||
|
|
||||||
enum AuthCookie: CaseIterable {
|
enum AuthCookie: CaseIterable {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>$(CUSTOM_GROUP_ID)</string>
|
<string>group.app.immich.share</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>$(CUSTOM_GROUP_ID)</string>
|
<string>group.app.immich.share</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
Generated
+118
-10
@@ -183,6 +183,12 @@ enum PlatformAssetPlaybackStyle: Int {
|
|||||||
case videoLooping = 5
|
case videoLooping = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum EditState: Int {
|
||||||
|
case notEdited = 0
|
||||||
|
case edited = 1
|
||||||
|
case unknown = 2
|
||||||
|
}
|
||||||
|
|
||||||
/// Generated class from Pigeon that represents data sent in messages.
|
/// Generated class from Pigeon that represents data sent in messages.
|
||||||
struct PlatformAsset: Hashable {
|
struct PlatformAsset: Hashable {
|
||||||
var id: String
|
var id: String
|
||||||
@@ -458,6 +464,52 @@ struct CloudIdResult: Hashable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generated class from Pigeon that represents data sent in messages.
|
||||||
|
struct BaseResource: Hashable {
|
||||||
|
var path: String
|
||||||
|
var sha1: String
|
||||||
|
var sizeBytes: Int64
|
||||||
|
var mimeType: String
|
||||||
|
|
||||||
|
|
||||||
|
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||||
|
static func fromList(_ pigeonVar_list: [Any?]) -> BaseResource? {
|
||||||
|
let path = pigeonVar_list[0] as! String
|
||||||
|
let sha1 = pigeonVar_list[1] as! String
|
||||||
|
let sizeBytes = pigeonVar_list[2] as! Int64
|
||||||
|
let mimeType = pigeonVar_list[3] as! String
|
||||||
|
|
||||||
|
return BaseResource(
|
||||||
|
path: path,
|
||||||
|
sha1: sha1,
|
||||||
|
sizeBytes: sizeBytes,
|
||||||
|
mimeType: mimeType
|
||||||
|
)
|
||||||
|
}
|
||||||
|
func toList() -> [Any?] {
|
||||||
|
return [
|
||||||
|
path,
|
||||||
|
sha1,
|
||||||
|
sizeBytes,
|
||||||
|
mimeType,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
static func == (lhs: BaseResource, rhs: BaseResource) -> Bool {
|
||||||
|
if Swift.type(of: lhs) != Swift.type(of: rhs) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return deepEqualsMessages(lhs.path, rhs.path) && deepEqualsMessages(lhs.sha1, rhs.sha1) && deepEqualsMessages(lhs.sizeBytes, rhs.sizeBytes) && deepEqualsMessages(lhs.mimeType, rhs.mimeType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine("BaseResource")
|
||||||
|
deepHashMessages(value: path, hasher: &hasher)
|
||||||
|
deepHashMessages(value: sha1, hasher: &hasher)
|
||||||
|
deepHashMessages(value: sizeBytes, hasher: &hasher)
|
||||||
|
deepHashMessages(value: mimeType, hasher: &hasher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private class MessagesPigeonCodecReader: FlutterStandardReader {
|
private class MessagesPigeonCodecReader: FlutterStandardReader {
|
||||||
override func readValue(ofType type: UInt8) -> Any? {
|
override func readValue(ofType type: UInt8) -> Any? {
|
||||||
switch type {
|
switch type {
|
||||||
@@ -468,15 +520,23 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
case 130:
|
case 130:
|
||||||
return PlatformAsset.fromList(self.readValue() as! [Any?])
|
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
|
||||||
|
if let enumResultAsInt = enumResultAsInt {
|
||||||
|
return EditState(rawValue: enumResultAsInt)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
case 131:
|
case 131:
|
||||||
return PlatformAlbum.fromList(self.readValue() as! [Any?])
|
return PlatformAsset.fromList(self.readValue() as! [Any?])
|
||||||
case 132:
|
case 132:
|
||||||
return SyncDelta.fromList(self.readValue() as! [Any?])
|
return PlatformAlbum.fromList(self.readValue() as! [Any?])
|
||||||
case 133:
|
case 133:
|
||||||
return HashResult.fromList(self.readValue() as! [Any?])
|
return SyncDelta.fromList(self.readValue() as! [Any?])
|
||||||
case 134:
|
case 134:
|
||||||
|
return HashResult.fromList(self.readValue() as! [Any?])
|
||||||
|
case 135:
|
||||||
return CloudIdResult.fromList(self.readValue() as! [Any?])
|
return CloudIdResult.fromList(self.readValue() as! [Any?])
|
||||||
|
case 136:
|
||||||
|
return BaseResource.fromList(self.readValue() as! [Any?])
|
||||||
default:
|
default:
|
||||||
return super.readValue(ofType: type)
|
return super.readValue(ofType: type)
|
||||||
}
|
}
|
||||||
@@ -488,21 +548,27 @@ private class MessagesPigeonCodecWriter: FlutterStandardWriter {
|
|||||||
if let value = value as? PlatformAssetPlaybackStyle {
|
if let value = value as? PlatformAssetPlaybackStyle {
|
||||||
super.writeByte(129)
|
super.writeByte(129)
|
||||||
super.writeValue(value.rawValue)
|
super.writeValue(value.rawValue)
|
||||||
} else if let value = value as? PlatformAsset {
|
} else if let value = value as? EditState {
|
||||||
super.writeByte(130)
|
super.writeByte(130)
|
||||||
super.writeValue(value.toList())
|
super.writeValue(value.rawValue)
|
||||||
} else if let value = value as? PlatformAlbum {
|
} else if let value = value as? PlatformAsset {
|
||||||
super.writeByte(131)
|
super.writeByte(131)
|
||||||
super.writeValue(value.toList())
|
super.writeValue(value.toList())
|
||||||
} else if let value = value as? SyncDelta {
|
} else if let value = value as? PlatformAlbum {
|
||||||
super.writeByte(132)
|
super.writeByte(132)
|
||||||
super.writeValue(value.toList())
|
super.writeValue(value.toList())
|
||||||
} else if let value = value as? HashResult {
|
} else if let value = value as? SyncDelta {
|
||||||
super.writeByte(133)
|
super.writeByte(133)
|
||||||
super.writeValue(value.toList())
|
super.writeValue(value.toList())
|
||||||
} else if let value = value as? CloudIdResult {
|
} else if let value = value as? HashResult {
|
||||||
super.writeByte(134)
|
super.writeByte(134)
|
||||||
super.writeValue(value.toList())
|
super.writeValue(value.toList())
|
||||||
|
} else if let value = value as? CloudIdResult {
|
||||||
|
super.writeByte(135)
|
||||||
|
super.writeValue(value.toList())
|
||||||
|
} else if let value = value as? BaseResource {
|
||||||
|
super.writeByte(136)
|
||||||
|
super.writeValue(value.toList())
|
||||||
} else {
|
} else {
|
||||||
super.writeValue(value)
|
super.writeValue(value)
|
||||||
}
|
}
|
||||||
@@ -538,6 +604,8 @@ protocol NativeSyncApi {
|
|||||||
func cancelHashing() throws
|
func cancelHashing() throws
|
||||||
func getTrashedAssets() throws -> [String: [PlatformAsset]]
|
func getTrashedAssets() throws -> [String: [PlatformAsset]]
|
||||||
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
|
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
|
||||||
|
func getBaseResource(assetId: String, allowNetworkAccess: Bool, completion: @escaping (Result<BaseResource?, Error>) -> Void)
|
||||||
|
func getEditState(assetId: String, allowNetworkAccess: Bool, completion: @escaping (Result<EditState, Error>) -> Void)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||||
@@ -738,5 +806,45 @@ class NativeSyncApiSetup {
|
|||||||
} else {
|
} else {
|
||||||
getCloudIdForAssetIdsChannel.setMessageHandler(nil)
|
getCloudIdForAssetIdsChannel.setMessageHandler(nil)
|
||||||
}
|
}
|
||||||
|
let getBaseResourceChannel = taskQueue == nil
|
||||||
|
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||||
|
if let api = api {
|
||||||
|
getBaseResourceChannel.setMessageHandler { message, reply in
|
||||||
|
let args = message as! [Any?]
|
||||||
|
let assetIdArg = args[0] as! String
|
||||||
|
let allowNetworkAccessArg = args[1] as! Bool
|
||||||
|
api.getBaseResource(assetId: assetIdArg, allowNetworkAccess: allowNetworkAccessArg) { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let res):
|
||||||
|
reply(wrapResult(res))
|
||||||
|
case .failure(let error):
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
getBaseResourceChannel.setMessageHandler(nil)
|
||||||
|
}
|
||||||
|
let getEditStateChannel = taskQueue == nil
|
||||||
|
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||||
|
if let api = api {
|
||||||
|
getEditStateChannel.setMessageHandler { message, reply in
|
||||||
|
let args = message as! [Any?]
|
||||||
|
let assetIdArg = args[0] as! String
|
||||||
|
let allowNetworkAccessArg = args[1] as! Bool
|
||||||
|
api.getEditState(assetId: assetIdArg, allowNetworkAccess: allowNetworkAccessArg) { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let res):
|
||||||
|
reply(wrapResult(res))
|
||||||
|
case .failure(let error):
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
getEditStateChannel.setMessageHandler(nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Photos
|
import Photos
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
struct AssetWrapper: Hashable, Equatable {
|
struct AssetWrapper: Hashable, Equatable {
|
||||||
let asset: PlatformAsset
|
let asset: PlatformAsset
|
||||||
@@ -415,4 +416,170 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
|||||||
}
|
}
|
||||||
return mappings;
|
return mappings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getBaseResource(
|
||||||
|
assetId: String,
|
||||||
|
allowNetworkAccess: Bool,
|
||||||
|
completion: @escaping (Result<BaseResource?, Error>) -> Void
|
||||||
|
) {
|
||||||
|
Task { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil).firstObject else {
|
||||||
|
return self.completeWhenActive(for: completion, with: .success(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
let resources = PHAssetResource.assetResources(for: asset)
|
||||||
|
let state = await Self.classifyEdit(resources: resources, allowNetworkAccess: allowNetworkAccess)
|
||||||
|
guard state == .edited, let original = resources.first(where: { $0.type == .photo }) else {
|
||||||
|
return self.completeWhenActive(for: completion, with: .success(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let result = try await self.streamBaseResource(
|
||||||
|
resource: original,
|
||||||
|
localId: asset.localIdentifier,
|
||||||
|
allowNetworkAccess: allowNetworkAccess
|
||||||
|
)
|
||||||
|
self.completeWhenActive(for: completion, with: .success(result))
|
||||||
|
} catch {
|
||||||
|
self.completeWhenActive(for: completion, with: .failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns whether the asset carries a live Photos edit without reading the photo
|
||||||
|
// itself, only the small adjustment metadata. The revert probe relies on this to
|
||||||
|
// tell "not edited" apart from "couldn't read" (offloaded to iCloud), so it never
|
||||||
|
// mistakes an unreadable edit for a revert.
|
||||||
|
func getEditState(
|
||||||
|
assetId: String,
|
||||||
|
allowNetworkAccess: Bool,
|
||||||
|
completion: @escaping (Result<EditState, Error>) -> Void
|
||||||
|
) {
|
||||||
|
Task { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil).firstObject else {
|
||||||
|
// Not in the library, so don't answer "not edited" (the caller acts on that).
|
||||||
|
return self.completeWhenActive(for: completion, with: .success(.unknown))
|
||||||
|
}
|
||||||
|
let state = await Self.classifyEdit(
|
||||||
|
resources: PHAssetResource.assetResources(for: asset),
|
||||||
|
allowNetworkAccess: allowNetworkAccess
|
||||||
|
)
|
||||||
|
self.completeWhenActive(for: completion, with: .success(state))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adjustmentRenderTypes for a photo with no real edit: a plain capture, a
|
||||||
|
// Photographic Style, or a reverted edit. A real edit changes this value.
|
||||||
|
private static let kNoEditRenderTypes = 27648
|
||||||
|
|
||||||
|
// Works out the edit state from Adjustments.plist only (never reads the photo). A
|
||||||
|
// real Photos edit is authored by com.apple.mobileslideshow (or a 3rd-party editor)
|
||||||
|
// and bumps the render types off the baseline. A plain capture (incl. a Photographic
|
||||||
|
// Style) is authored by com.apple.camera, and a revert keeps the editor id but resets
|
||||||
|
// the render types to baseline, so we need both to call it an edit. Cleanup and
|
||||||
|
// object-removal stay camera-authored but write AdjustmentsSecondary.data, which we
|
||||||
|
// count as edited. unknown = couldn't read the plist (offloaded, no network).
|
||||||
|
private static func classifyEdit(resources: [PHAssetResource], allowNetworkAccess: Bool) async -> EditState {
|
||||||
|
if resources.contains(where: { $0.originalFilename == "AdjustmentsSecondary.data" }) {
|
||||||
|
return .edited
|
||||||
|
}
|
||||||
|
guard let adjRes = resources.first(where: { $0.originalFilename == "Adjustments.plist" }) else {
|
||||||
|
return .notEdited
|
||||||
|
}
|
||||||
|
guard let buf = await collectResourceData(adjRes, allowNetworkAccess: allowNetworkAccess),
|
||||||
|
let plist = try? PropertyListSerialization.propertyList(from: buf, options: [], format: nil) as? [String: Any]
|
||||||
|
else {
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
let editor = plist["adjustmentEditorBundleID"] as? String
|
||||||
|
let renderTypes = (plist["adjustmentRenderTypes"] as? NSNumber)?.intValue
|
||||||
|
let isUserEdit = editor != nil && editor != "com.apple.camera" && renderTypes != kNoEditRenderTypes
|
||||||
|
return isUserEdit ? .edited : .notEdited
|
||||||
|
}
|
||||||
|
|
||||||
|
private func streamBaseResource(
|
||||||
|
resource: PHAssetResource,
|
||||||
|
localId: String,
|
||||||
|
allowNetworkAccess: Bool
|
||||||
|
) async throws -> BaseResource {
|
||||||
|
let safeId = localId.replacingOccurrences(of: "/", with: "_")
|
||||||
|
let suffix = UTType(resource.uniformTypeIdentifier)?.preferredFilenameExtension ?? "bin"
|
||||||
|
let tempDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||||
|
.appendingPathComponent("immich_base", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let unique = UUID().uuidString.prefix(8)
|
||||||
|
let tempUrl = tempDir.appendingPathComponent("\(safeId)_\(unique)_base.\(suffix)")
|
||||||
|
|
||||||
|
// Write the resource to disk and hash it chunk by chunk, so a big original (e.g.
|
||||||
|
// ProRAW) never sits fully in memory on the upload thread.
|
||||||
|
FileManager.default.createFile(atPath: tempUrl.path, contents: nil)
|
||||||
|
guard let handle = try? FileHandle(forWritingTo: tempUrl) else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "NativeSyncApi",
|
||||||
|
code: -1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Failed to open temp file for base resource \(localId)"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasher = Insecure.SHA1()
|
||||||
|
var totalBytes: Int64 = 0
|
||||||
|
let options = PHAssetResourceRequestOptions()
|
||||||
|
options.isNetworkAccessAllowed = allowNetworkAccess
|
||||||
|
|
||||||
|
let succeeded = await withCheckedContinuation { (continuation: CheckedContinuation<Bool, Never>) in
|
||||||
|
var writeFailed = false
|
||||||
|
PHAssetResourceManager.default().requestData(
|
||||||
|
for: resource,
|
||||||
|
options: options,
|
||||||
|
dataReceivedHandler: { chunk in
|
||||||
|
if writeFailed { return }
|
||||||
|
do {
|
||||||
|
try handle.write(contentsOf: chunk)
|
||||||
|
hasher.update(data: chunk)
|
||||||
|
totalBytes += Int64(chunk.count)
|
||||||
|
} catch {
|
||||||
|
writeFailed = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
completionHandler: { error in continuation.resume(returning: error == nil && !writeFailed) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try? handle.close()
|
||||||
|
|
||||||
|
guard succeeded else {
|
||||||
|
try? FileManager.default.removeItem(at: tempUrl)
|
||||||
|
throw NSError(
|
||||||
|
domain: "NativeSyncApi",
|
||||||
|
code: -1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Failed to read base resource for \(localId)"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let sha1 = Data(hasher.finalize()).base64EncodedString()
|
||||||
|
let mime = UTType(resource.uniformTypeIdentifier)?.preferredMIMEType ?? "application/octet-stream"
|
||||||
|
return BaseResource(path: tempUrl.path, sha1: sha1, sizeBytes: totalBytes, mimeType: mime)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func collectResourceData(
|
||||||
|
_ resource: PHAssetResource,
|
||||||
|
allowNetworkAccess: Bool
|
||||||
|
) async -> Data? {
|
||||||
|
let options = PHAssetResourceRequestOptions()
|
||||||
|
options.isNetworkAccessAllowed = allowNetworkAccess
|
||||||
|
var buffer = Data()
|
||||||
|
return await withCheckedContinuation { (continuation: CheckedContinuation<Data?, Never>) in
|
||||||
|
PHAssetResourceManager.default().requestData(
|
||||||
|
for: resource,
|
||||||
|
options: options,
|
||||||
|
dataReceivedHandler: { data in buffer.append(data) },
|
||||||
|
completionHandler: { error in continuation.resume(returning: error == nil ? buffer : nil) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>$(CUSTOM_GROUP_ID)</string>
|
<string>group.app.immich.share</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -2,7 +2,7 @@ import Foundation
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
|
|
||||||
let IMMICH_SHARE_GROUP = Bundle.main.object(forInfoDictionaryKey: "AppGroupId") as! String
|
let IMMICH_SHARE_GROUP = "group.app.immich.share"
|
||||||
|
|
||||||
enum WidgetError: Error, Codable {
|
enum WidgetError: Error, Codable {
|
||||||
case noLogin
|
case noLogin
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>AppGroupId</key>
|
|
||||||
<string>$(CUSTOM_GROUP_ID)</string>
|
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>$(CUSTOM_GROUP_ID)</string>
|
<string>group.app.immich.share</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -21,7 +21,6 @@ platform :ios do
|
|||||||
CODE_SIGN_IDENTITY = "Apple Distribution: FUTO Holdings, Inc. (#{TEAM_ID})"
|
CODE_SIGN_IDENTITY = "Apple Distribution: FUTO Holdings, Inc. (#{TEAM_ID})"
|
||||||
BASE_BUNDLE_ID = "app.alextran.immich"
|
BASE_BUNDLE_ID = "app.alextran.immich"
|
||||||
DEV_BUNDLE_ID = "tech.futo.immich.testflight"
|
DEV_BUNDLE_ID = "tech.futo.immich.testflight"
|
||||||
DEV_GROUP_ID = "group.app.immich.share.testflight"
|
|
||||||
|
|
||||||
# Helper method to get App Store Connect API key
|
# Helper method to get App Store Connect API key
|
||||||
def get_api_key
|
def get_api_key
|
||||||
@@ -34,13 +33,6 @@ platform :ios do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper method to assemble xcargs with optional CUSTOM_GROUP_ID override
|
|
||||||
def build_xcargs(group_id: nil)
|
|
||||||
args = "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual"
|
|
||||||
args += " CUSTOM_GROUP_ID='#{group_id}'" if group_id
|
|
||||||
args
|
|
||||||
end
|
|
||||||
|
|
||||||
# Helper method to get version from pubspec.yaml
|
# Helper method to get version from pubspec.yaml
|
||||||
def get_version_from_pubspec
|
def get_version_from_pubspec
|
||||||
require 'yaml'
|
require 'yaml'
|
||||||
@@ -97,8 +89,7 @@ end
|
|||||||
version_number: nil,
|
version_number: nil,
|
||||||
profile_name_main:,
|
profile_name_main:,
|
||||||
profile_name_share:,
|
profile_name_share:,
|
||||||
profile_name_widget:,
|
profile_name_widget:
|
||||||
group_id: nil
|
|
||||||
)
|
)
|
||||||
app_identifier = base_bundle_id
|
app_identifier = base_bundle_id
|
||||||
|
|
||||||
@@ -122,7 +113,7 @@ end
|
|||||||
workspace: "Runner.xcworkspace",
|
workspace: "Runner.xcworkspace",
|
||||||
configuration: configuration,
|
configuration: configuration,
|
||||||
export_method: "app-store",
|
export_method: "app-store",
|
||||||
xcargs: build_xcargs(group_id: group_id),
|
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
|
||||||
export_options: {
|
export_options: {
|
||||||
provisioningProfiles: {
|
provisioningProfiles: {
|
||||||
"#{app_identifier}" => profile_name_main,
|
"#{app_identifier}" => profile_name_main,
|
||||||
@@ -174,8 +165,7 @@ end
|
|||||||
distribute_external: false,
|
distribute_external: false,
|
||||||
profile_name_main: main_profile_name,
|
profile_name_main: main_profile_name,
|
||||||
profile_name_share: share_profile_name,
|
profile_name_share: share_profile_name,
|
||||||
profile_name_widget: widget_profile_name,
|
profile_name_widget: widget_profile_name
|
||||||
group_id: DEV_GROUP_ID
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -284,7 +274,7 @@ end
|
|||||||
configuration: "Release",
|
configuration: "Release",
|
||||||
export_method: "app-store",
|
export_method: "app-store",
|
||||||
skip_package_ipa: true,
|
skip_package_ipa: true,
|
||||||
xcargs: build_xcargs(group_id: DEV_GROUP_ID),
|
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
|
||||||
export_options: {
|
export_options: {
|
||||||
provisioningProfiles: {
|
provisioningProfiles: {
|
||||||
DEV_BUNDLE_ID => main_profile_name,
|
DEV_BUNDLE_ID => main_profile_name,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const String kSecuredPinCode = "secured_pin_code";
|
|||||||
const String kManualUploadGroup = 'manual_upload_group';
|
const String kManualUploadGroup = 'manual_upload_group';
|
||||||
const String kBackupGroup = 'backup_group';
|
const String kBackupGroup = 'backup_group';
|
||||||
const String kBackupLivePhotoGroup = 'backup_live_photo_group';
|
const String kBackupLivePhotoGroup = 'backup_live_photo_group';
|
||||||
|
const String kBackupEditPairGroup = 'backup_edit_pair_group';
|
||||||
const String kDownloadGroupImage = 'group_image';
|
const String kDownloadGroupImage = 'group_image';
|
||||||
const String kDownloadGroupVideo = 'group_video';
|
const String kDownloadGroupVideo = 'group_video';
|
||||||
const String kDownloadGroupLivePhoto = 'group_livephoto';
|
const String kDownloadGroupLivePhoto = 'group_livephoto';
|
||||||
@@ -30,6 +31,7 @@ const int kTimelineAssetLoadBatchSize = 1024;
|
|||||||
const int kTimelineAssetLoadOppositeSize = 64;
|
const int kTimelineAssetLoadOppositeSize = 64;
|
||||||
|
|
||||||
// Widget keys
|
// Widget keys
|
||||||
|
const String appShareGroupId = "group.app.immich.share";
|
||||||
const String kWidgetAuthToken = "widget_auth_token";
|
const String kWidgetAuthToken = "widget_auth_token";
|
||||||
const String kWidgetServerEndpoint = "widget_server_url";
|
const String kWidgetServerEndpoint = "widget_server_url";
|
||||||
const String kWidgetCustomHeaders = "widget_custom_headers";
|
const String kWidgetCustomHeaders = "widget_custom_headers";
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ class LocalAsset extends BaseAsset {
|
|||||||
final double? latitude;
|
final double? latitude;
|
||||||
final double? longitude;
|
final double? longitude;
|
||||||
|
|
||||||
|
// Remote id of this asset's previous upload; used to stack a new edit under it.
|
||||||
|
final String? priorRemoteId;
|
||||||
|
|
||||||
|
// Local checksum at the last sync action; lets backup skip an already-handled
|
||||||
|
// local whose current render hashes fresh (the iOS revert case).
|
||||||
|
final String? syncedChecksum;
|
||||||
|
|
||||||
const LocalAsset({
|
const LocalAsset({
|
||||||
required this.id,
|
required this.id,
|
||||||
String? remoteId,
|
String? remoteId,
|
||||||
@@ -32,6 +39,8 @@ class LocalAsset extends BaseAsset {
|
|||||||
this.latitude,
|
this.latitude,
|
||||||
this.longitude,
|
this.longitude,
|
||||||
required super.isEdited,
|
required super.isEdited,
|
||||||
|
this.priorRemoteId,
|
||||||
|
this.syncedChecksum,
|
||||||
}) : remoteAssetId = remoteId;
|
}) : remoteAssetId = remoteId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -120,6 +129,8 @@ class LocalAsset extends BaseAsset {
|
|||||||
double? latitude,
|
double? latitude,
|
||||||
double? longitude,
|
double? longitude,
|
||||||
bool? isEdited,
|
bool? isEdited,
|
||||||
|
String? priorRemoteId,
|
||||||
|
String? syncedChecksum,
|
||||||
}) {
|
}) {
|
||||||
return LocalAsset(
|
return LocalAsset(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -140,6 +151,8 @@ class LocalAsset extends BaseAsset {
|
|||||||
latitude: latitude ?? this.latitude,
|
latitude: latitude ?? this.latitude,
|
||||||
longitude: longitude ?? this.longitude,
|
longitude: longitude ?? this.longitude,
|
||||||
isEdited: isEdited ?? this.isEdited,
|
isEdited: isEdited ?? this.isEdited,
|
||||||
|
priorRemoteId: priorRemoteId ?? this.priorRemoteId,
|
||||||
|
syncedChecksum: syncedChecksum ?? this.syncedChecksum,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
|
||||||
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
|
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
/// Handles an edit that was reverted in Photos. The local was uploaded as an edit
|
||||||
|
/// before but isn't edited now, so flip the stack primary back to the original (via
|
||||||
|
/// prior_remote_id) and mark it handled so we don't re-upload the reverted render.
|
||||||
|
/// Nothing is trashed; all the edits stay in the stack.
|
||||||
|
class EditRevertService {
|
||||||
|
final NativeSyncApi _nativeSyncApi;
|
||||||
|
final DriftStackRepository _stackRepository;
|
||||||
|
final DriftLocalAssetRepository _localAssetRepository;
|
||||||
|
final AssetApiRepository _assetApiRepository;
|
||||||
|
final _log = Logger('EditRevertService');
|
||||||
|
|
||||||
|
EditRevertService({
|
||||||
|
required NativeSyncApi nativeSyncApi,
|
||||||
|
required DriftStackRepository stackRepository,
|
||||||
|
required DriftLocalAssetRepository localAssetRepository,
|
||||||
|
required AssetApiRepository assetApiRepository,
|
||||||
|
}) : _nativeSyncApi = nativeSyncApi,
|
||||||
|
_stackRepository = stackRepository,
|
||||||
|
_localAssetRepository = localAssetRepository,
|
||||||
|
_assetApiRepository = assetApiRepository;
|
||||||
|
|
||||||
|
/// Returns true if the asset was a revert and was handled (caller skips the
|
||||||
|
/// upload); false to fall through to the normal upload path.
|
||||||
|
Future<bool> tryHandleRevert(LocalAsset asset) async {
|
||||||
|
if (asset.priorRemoteId == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only "not edited" is a revert. `edited` is a fresh edit, so let the pair flow
|
||||||
|
// take it. `unknown` means we couldn't read the adjustment (offloaded to iCloud,
|
||||||
|
// network off); bail there too instead of mistaking an unreadable edit for a
|
||||||
|
// revert and flipping the stack. Network off keeps this a cheap offline read.
|
||||||
|
try {
|
||||||
|
final editState = await _nativeSyncApi.getEditState(asset.id, allowNetworkAccess: false);
|
||||||
|
if (editState != EditState.notEdited) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error, stack) {
|
||||||
|
_log.warning("edit-state probe failed for ${asset.id}", error, stack);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// It's a revert. Styled photos hit this path because iOS re-encodes the revert to
|
||||||
|
// fresh bytes, so it looks like a new backup candidate and reaches upload.
|
||||||
|
// Non-styled reverts hash back to the base instead, aren't candidates, and get
|
||||||
|
// flipped at hash time in HashService._reconcileReverts. Fresh bytes match nothing
|
||||||
|
// remote, so flip by structure: prior_remote_id is the current primary (the latest
|
||||||
|
// edit), flip it back to the base.
|
||||||
|
final String stackId;
|
||||||
|
final String baseId;
|
||||||
|
try {
|
||||||
|
final foundStack = await _stackRepository.findStackIdByRemoteId(asset.priorRemoteId!);
|
||||||
|
if (foundStack == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final base = await _stackRepository.findStackBaseId(foundStack, excludeId: asset.priorRemoteId!);
|
||||||
|
if (base == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
stackId = foundStack;
|
||||||
|
baseId = base;
|
||||||
|
} catch (error, stack) {
|
||||||
|
_log.warning("revert stack lookup failed for ${asset.id}", error, stack);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _assetApiRepository.setStackPrimary(stackId, baseId);
|
||||||
|
await _stackRepository.setPrimary(stackId, baseId);
|
||||||
|
await _localAssetRepository.markSynced(asset.id, priorRemoteId: baseId, syncedChecksum: asset.checksum ?? '');
|
||||||
|
} catch (error, stack) {
|
||||||
|
_log.warning("revert primary flip failed for ${asset.id}", error, stack);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
|
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
const String _kHashCancelledCode = "HASH_CANCELLED";
|
const String _kHashCancelledCode = "HASH_CANCELLED";
|
||||||
@@ -17,6 +19,8 @@ class HashService {
|
|||||||
final DriftLocalAssetRepository _localAssetRepository;
|
final DriftLocalAssetRepository _localAssetRepository;
|
||||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||||
final NativeSyncApi _nativeSyncApi;
|
final NativeSyncApi _nativeSyncApi;
|
||||||
|
final DriftStackRepository _stackRepository;
|
||||||
|
final AssetApiRepository _assetApiRepository;
|
||||||
final bool Function()? _cancelChecker;
|
final bool Function()? _cancelChecker;
|
||||||
final _log = Logger('HashService');
|
final _log = Logger('HashService');
|
||||||
|
|
||||||
@@ -25,6 +29,8 @@ class HashService {
|
|||||||
required DriftLocalAssetRepository localAssetRepository,
|
required DriftLocalAssetRepository localAssetRepository,
|
||||||
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
||||||
required NativeSyncApi nativeSyncApi,
|
required NativeSyncApi nativeSyncApi,
|
||||||
|
required DriftStackRepository stackRepository,
|
||||||
|
required AssetApiRepository assetApiRepository,
|
||||||
bool Function()? cancelChecker,
|
bool Function()? cancelChecker,
|
||||||
int? batchSize,
|
int? batchSize,
|
||||||
}) : _localAlbumRepository = localAlbumRepository,
|
}) : _localAlbumRepository = localAlbumRepository,
|
||||||
@@ -32,6 +38,8 @@ class HashService {
|
|||||||
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
||||||
_cancelChecker = cancelChecker,
|
_cancelChecker = cancelChecker,
|
||||||
_nativeSyncApi = nativeSyncApi,
|
_nativeSyncApi = nativeSyncApi,
|
||||||
|
_stackRepository = stackRepository,
|
||||||
|
_assetApiRepository = assetApiRepository,
|
||||||
_batchSize = batchSize ?? kBatchHashFileLimit;
|
_batchSize = batchSize ?? kBatchHashFileLimit;
|
||||||
|
|
||||||
bool get isCancelled => _cancelChecker?.call() ?? false;
|
bool get isCancelled => _cancelChecker?.call() ?? false;
|
||||||
@@ -45,6 +53,7 @@ class HashService {
|
|||||||
|
|
||||||
// Sorted by backupSelection followed by isCloud
|
// Sorted by backupSelection followed by isCloud
|
||||||
final localAlbums = await _localAlbumRepository.getBackupAlbums();
|
final localAlbums = await _localAlbumRepository.getBackupAlbums();
|
||||||
|
final hashedIds = <String>{};
|
||||||
|
|
||||||
for (final album in localAlbums) {
|
for (final album in localAlbums) {
|
||||||
if (isCancelled) {
|
if (isCancelled) {
|
||||||
@@ -54,7 +63,7 @@ class HashService {
|
|||||||
|
|
||||||
final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
|
final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
|
||||||
if (assetsToHash.isNotEmpty) {
|
if (assetsToHash.isNotEmpty) {
|
||||||
await _hashAssets(album, assetsToHash);
|
await _hashAssets(album, assetsToHash, hashedIds: hashedIds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (CurrentPlatform.isAndroid && localAlbums.isNotEmpty) {
|
if (CurrentPlatform.isAndroid && localAlbums.isNotEmpty) {
|
||||||
@@ -62,9 +71,18 @@ class HashService {
|
|||||||
final trashedToHash = await _trashedLocalAssetRepository.getAssetsToHash(backupAlbumIds);
|
final trashedToHash = await _trashedLocalAssetRepository.getAssetsToHash(backupAlbumIds);
|
||||||
if (trashedToHash.isNotEmpty) {
|
if (trashedToHash.isNotEmpty) {
|
||||||
final pseudoAlbum = LocalAlbum(id: '-pseudoAlbum', name: 'Trash', updatedAt: DateTime.now());
|
final pseudoAlbum = LocalAlbum(id: '-pseudoAlbum', name: 'Trash', updatedAt: DateTime.now());
|
||||||
await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true);
|
await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true, hashedIds: hashedIds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Revert reconcile for non-styled photos: the reverted edit hashes back to the
|
||||||
|
// original's exact bytes, which are already the stack base, so it's not a backup
|
||||||
|
// candidate and never reaches upload. Flip the primary here. Styled photos
|
||||||
|
// re-encode to fresh bytes and get flipped on the upload path instead
|
||||||
|
// (EditRevertService.tryHandleRevert).
|
||||||
|
if (CurrentPlatform.isIOS && hashedIds.isNotEmpty && !isCancelled) {
|
||||||
|
await _reconcileReverts(hashedIds);
|
||||||
|
}
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
if (e.code == _kHashCancelledCode) {
|
if (e.code == _kHashCancelledCode) {
|
||||||
_log.warning("Hashing cancelled by platform");
|
_log.warning("Hashing cancelled by platform");
|
||||||
@@ -81,7 +99,12 @@ class HashService {
|
|||||||
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
|
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
|
||||||
/// with hash for those that were successfully hashed. Hashes are looked up in a table
|
/// with hash for those that were successfully hashed. Hashes are looked up in a table
|
||||||
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
|
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
|
||||||
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash, {bool isTrashed = false}) async {
|
Future<void> _hashAssets(
|
||||||
|
LocalAlbum album,
|
||||||
|
List<LocalAsset> assetsToHash, {
|
||||||
|
bool isTrashed = false,
|
||||||
|
required Set<String> hashedIds,
|
||||||
|
}) async {
|
||||||
final toHash = <String, LocalAsset>{};
|
final toHash = <String, LocalAsset>{};
|
||||||
|
|
||||||
for (final asset in assetsToHash) {
|
for (final asset in assetsToHash) {
|
||||||
@@ -92,16 +115,21 @@ class HashService {
|
|||||||
|
|
||||||
toHash[asset.id] = asset;
|
toHash[asset.id] = asset;
|
||||||
if (toHash.length == _batchSize) {
|
if (toHash.length == _batchSize) {
|
||||||
await _processBatch(album, toHash, isTrashed);
|
await _processBatch(album, toHash, isTrashed, hashedIds);
|
||||||
toHash.clear();
|
toHash.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await _processBatch(album, toHash, isTrashed);
|
await _processBatch(album, toHash, isTrashed, hashedIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Processes a batch of assets.
|
/// Processes a batch of assets.
|
||||||
Future<void> _processBatch(LocalAlbum album, Map<String, LocalAsset> toHash, bool isTrashed) async {
|
Future<void> _processBatch(
|
||||||
|
LocalAlbum album,
|
||||||
|
Map<String, LocalAsset> toHash,
|
||||||
|
bool isTrashed,
|
||||||
|
Set<String> hashedIds,
|
||||||
|
) async {
|
||||||
if (toHash.isEmpty) {
|
if (toHash.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -141,5 +169,33 @@ class HashService {
|
|||||||
} else {
|
} else {
|
||||||
await _localAssetRepository.updateHashes(hashed);
|
await _localAssetRepository.updateHashes(hashed);
|
||||||
}
|
}
|
||||||
|
hashedIds.addAll(hashed.keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _reconcileReverts(Set<String> localIds) async {
|
||||||
|
final List<StackReconcileTarget> targets;
|
||||||
|
try {
|
||||||
|
targets = await _stackRepository.findRevertReconcileTargets(localIds);
|
||||||
|
} catch (error, stack) {
|
||||||
|
_log.warning("findRevertReconcileTargets failed", error, stack);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final target in targets) {
|
||||||
|
try {
|
||||||
|
await _assetApiRepository.setStackPrimary(target.stackId, target.newPrimaryId);
|
||||||
|
await _stackRepository.setPrimary(target.stackId, target.newPrimaryId);
|
||||||
|
// Roll priorRemoteId forward to the matched member (now the primary) so a
|
||||||
|
// later edit stacks onto THAT (the current render), not the old edit.
|
||||||
|
await _localAssetRepository.markSynced(
|
||||||
|
target.localAssetId,
|
||||||
|
priorRemoteId: target.newPrimaryId,
|
||||||
|
syncedChecksum: target.localAssetChecksum,
|
||||||
|
);
|
||||||
|
} catch (error, stack) {
|
||||||
|
_log.warning("revert reconcile flip failed for stack ${target.stackId}", error, stack);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,6 +186,22 @@ class BackgroundSyncManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs a remote sync guaranteed to observe changes up to now. [syncRemote]
|
||||||
|
/// joins an in-flight sync whose snapshot can pre-date a just-received change
|
||||||
|
/// (e.g. a stack update) and miss it, so wait for any in-flight sync to finish
|
||||||
|
/// first, then run a fresh one.
|
||||||
|
Future<void> runFreshRemoteSync() async {
|
||||||
|
final inflight = _syncTask;
|
||||||
|
if (inflight != null) {
|
||||||
|
try {
|
||||||
|
await inflight.future;
|
||||||
|
} catch (_) {
|
||||||
|
// The in-flight sync's outcome doesn't matter; we only need a fresh one after it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await syncRemote();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> syncWebsocketBatchV1(List<dynamic> batchData) {
|
Future<void> syncWebsocketBatchV1(List<dynamic> batchData) {
|
||||||
if (_syncWebsocketTask != null) {
|
if (_syncWebsocketTask != null) {
|
||||||
return _syncWebsocketTask!.future;
|
return _syncWebsocketTask!.future;
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
|||||||
|
|
||||||
IntColumn get playbackStyle => intEnum<AssetPlaybackStyle>().withDefault(const Constant(0))();
|
IntColumn get playbackStyle => intEnum<AssetPlaybackStyle>().withDefault(const Constant(0))();
|
||||||
|
|
||||||
|
// remote id of the previous upload (iOS edit-pair stacking)
|
||||||
|
TextColumn get priorRemoteId => text().nullable()();
|
||||||
|
|
||||||
|
// local checksum at the last sync action. Lets the backup query skip a local
|
||||||
|
// whose current hash matches nothing remote but is still "handled" — the iOS
|
||||||
|
// revert case, where the reverted render hashes fresh but is already reconciled.
|
||||||
|
TextColumn get syncedChecksum => text().nullable()();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Set<Column> get primaryKey => {id};
|
Set<Column> get primaryKey => {id};
|
||||||
}
|
}
|
||||||
@@ -51,5 +59,7 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
|
|||||||
longitude: longitude,
|
longitude: longitude,
|
||||||
cloudId: iCloudId,
|
cloudId: iCloudId,
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
|
priorRemoteId: priorRemoteId,
|
||||||
|
syncedChecksum: syncedChecksum,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+151
-3
@@ -26,6 +26,8 @@ typedef $$LocalAssetEntityTableCreateCompanionBuilder =
|
|||||||
i0.Value<double?> latitude,
|
i0.Value<double?> latitude,
|
||||||
i0.Value<double?> longitude,
|
i0.Value<double?> longitude,
|
||||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
||||||
|
i0.Value<String?> priorRemoteId,
|
||||||
|
i0.Value<String?> syncedChecksum,
|
||||||
});
|
});
|
||||||
typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
|
typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
|
||||||
i1.LocalAssetEntityCompanion Function({
|
i1.LocalAssetEntityCompanion Function({
|
||||||
@@ -45,6 +47,8 @@ typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
|
|||||||
i0.Value<double?> latitude,
|
i0.Value<double?> latitude,
|
||||||
i0.Value<double?> longitude,
|
i0.Value<double?> longitude,
|
||||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
||||||
|
i0.Value<String?> priorRemoteId,
|
||||||
|
i0.Value<String?> syncedChecksum,
|
||||||
});
|
});
|
||||||
|
|
||||||
class $$LocalAssetEntityTableFilterComposer
|
class $$LocalAssetEntityTableFilterComposer
|
||||||
@@ -141,6 +145,16 @@ class $$LocalAssetEntityTableFilterComposer
|
|||||||
column: $table.playbackStyle,
|
column: $table.playbackStyle,
|
||||||
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
|
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
i0.ColumnFilters<String> get priorRemoteId => $composableBuilder(
|
||||||
|
column: $table.priorRemoteId,
|
||||||
|
builder: (column) => i0.ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
i0.ColumnFilters<String> get syncedChecksum => $composableBuilder(
|
||||||
|
column: $table.syncedChecksum,
|
||||||
|
builder: (column) => i0.ColumnFilters(column),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class $$LocalAssetEntityTableOrderingComposer
|
class $$LocalAssetEntityTableOrderingComposer
|
||||||
@@ -231,6 +245,16 @@ class $$LocalAssetEntityTableOrderingComposer
|
|||||||
column: $table.playbackStyle,
|
column: $table.playbackStyle,
|
||||||
builder: (column) => i0.ColumnOrderings(column),
|
builder: (column) => i0.ColumnOrderings(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
i0.ColumnOrderings<String> get priorRemoteId => $composableBuilder(
|
||||||
|
column: $table.priorRemoteId,
|
||||||
|
builder: (column) => i0.ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
i0.ColumnOrderings<String> get syncedChecksum => $composableBuilder(
|
||||||
|
column: $table.syncedChecksum,
|
||||||
|
builder: (column) => i0.ColumnOrderings(column),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class $$LocalAssetEntityTableAnnotationComposer
|
class $$LocalAssetEntityTableAnnotationComposer
|
||||||
@@ -300,6 +324,16 @@ class $$LocalAssetEntityTableAnnotationComposer
|
|||||||
column: $table.playbackStyle,
|
column: $table.playbackStyle,
|
||||||
builder: (column) => column,
|
builder: (column) => column,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
i0.GeneratedColumn<String> get priorRemoteId => $composableBuilder(
|
||||||
|
column: $table.priorRemoteId,
|
||||||
|
builder: (column) => column,
|
||||||
|
);
|
||||||
|
|
||||||
|
i0.GeneratedColumn<String> get syncedChecksum => $composableBuilder(
|
||||||
|
column: $table.syncedChecksum,
|
||||||
|
builder: (column) => column,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class $$LocalAssetEntityTableTableManager
|
class $$LocalAssetEntityTableTableManager
|
||||||
@@ -359,6 +393,8 @@ class $$LocalAssetEntityTableTableManager
|
|||||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
||||||
const i0.Value.absent(),
|
const i0.Value.absent(),
|
||||||
|
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
|
||||||
|
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
|
||||||
}) => i1.LocalAssetEntityCompanion(
|
}) => i1.LocalAssetEntityCompanion(
|
||||||
name: name,
|
name: name,
|
||||||
type: type,
|
type: type,
|
||||||
@@ -376,6 +412,8 @@ class $$LocalAssetEntityTableTableManager
|
|||||||
latitude: latitude,
|
latitude: latitude,
|
||||||
longitude: longitude,
|
longitude: longitude,
|
||||||
playbackStyle: playbackStyle,
|
playbackStyle: playbackStyle,
|
||||||
|
priorRemoteId: priorRemoteId,
|
||||||
|
syncedChecksum: syncedChecksum,
|
||||||
),
|
),
|
||||||
createCompanionCallback:
|
createCompanionCallback:
|
||||||
({
|
({
|
||||||
@@ -396,6 +434,8 @@ class $$LocalAssetEntityTableTableManager
|
|||||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
||||||
const i0.Value.absent(),
|
const i0.Value.absent(),
|
||||||
|
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
|
||||||
|
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
|
||||||
}) => i1.LocalAssetEntityCompanion.insert(
|
}) => i1.LocalAssetEntityCompanion.insert(
|
||||||
name: name,
|
name: name,
|
||||||
type: type,
|
type: type,
|
||||||
@@ -413,6 +453,8 @@ class $$LocalAssetEntityTableTableManager
|
|||||||
latitude: latitude,
|
latitude: latitude,
|
||||||
longitude: longitude,
|
longitude: longitude,
|
||||||
playbackStyle: playbackStyle,
|
playbackStyle: playbackStyle,
|
||||||
|
priorRemoteId: priorRemoteId,
|
||||||
|
syncedChecksum: syncedChecksum,
|
||||||
),
|
),
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
||||||
@@ -637,6 +679,28 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
|||||||
).withConverter<i2.AssetPlaybackStyle>(
|
).withConverter<i2.AssetPlaybackStyle>(
|
||||||
i1.$LocalAssetEntityTable.$converterplaybackStyle,
|
i1.$LocalAssetEntityTable.$converterplaybackStyle,
|
||||||
);
|
);
|
||||||
|
static const i0.VerificationMeta _priorRemoteIdMeta =
|
||||||
|
const i0.VerificationMeta('priorRemoteId');
|
||||||
|
@override
|
||||||
|
late final i0.GeneratedColumn<String> priorRemoteId =
|
||||||
|
i0.GeneratedColumn<String>(
|
||||||
|
'prior_remote_id',
|
||||||
|
aliasedName,
|
||||||
|
true,
|
||||||
|
type: i0.DriftSqlType.string,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
);
|
||||||
|
static const i0.VerificationMeta _syncedChecksumMeta =
|
||||||
|
const i0.VerificationMeta('syncedChecksum');
|
||||||
|
@override
|
||||||
|
late final i0.GeneratedColumn<String> syncedChecksum =
|
||||||
|
i0.GeneratedColumn<String>(
|
||||||
|
'synced_checksum',
|
||||||
|
aliasedName,
|
||||||
|
true,
|
||||||
|
type: i0.DriftSqlType.string,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
);
|
||||||
@override
|
@override
|
||||||
List<i0.GeneratedColumn> get $columns => [
|
List<i0.GeneratedColumn> get $columns => [
|
||||||
name,
|
name,
|
||||||
@@ -655,6 +719,8 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
|||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
playbackStyle,
|
playbackStyle,
|
||||||
|
priorRemoteId,
|
||||||
|
syncedChecksum,
|
||||||
];
|
];
|
||||||
@override
|
@override
|
||||||
String get aliasedName => _alias ?? actualTableName;
|
String get aliasedName => _alias ?? actualTableName;
|
||||||
@@ -759,6 +825,24 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
|||||||
longitude.isAcceptableOrUnknown(data['longitude']!, _longitudeMeta),
|
longitude.isAcceptableOrUnknown(data['longitude']!, _longitudeMeta),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (data.containsKey('prior_remote_id')) {
|
||||||
|
context.handle(
|
||||||
|
_priorRemoteIdMeta,
|
||||||
|
priorRemoteId.isAcceptableOrUnknown(
|
||||||
|
data['prior_remote_id']!,
|
||||||
|
_priorRemoteIdMeta,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data.containsKey('synced_checksum')) {
|
||||||
|
context.handle(
|
||||||
|
_syncedChecksumMeta,
|
||||||
|
syncedChecksum.isAcceptableOrUnknown(
|
||||||
|
data['synced_checksum']!,
|
||||||
|
_syncedChecksumMeta,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -839,6 +923,14 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
|||||||
data['${effectivePrefix}playback_style'],
|
data['${effectivePrefix}playback_style'],
|
||||||
)!,
|
)!,
|
||||||
),
|
),
|
||||||
|
priorRemoteId: attachedDatabase.typeMapping.read(
|
||||||
|
i0.DriftSqlType.string,
|
||||||
|
data['${effectivePrefix}prior_remote_id'],
|
||||||
|
),
|
||||||
|
syncedChecksum: attachedDatabase.typeMapping.read(
|
||||||
|
i0.DriftSqlType.string,
|
||||||
|
data['${effectivePrefix}synced_checksum'],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -877,6 +969,8 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
final double? latitude;
|
final double? latitude;
|
||||||
final double? longitude;
|
final double? longitude;
|
||||||
final i2.AssetPlaybackStyle playbackStyle;
|
final i2.AssetPlaybackStyle playbackStyle;
|
||||||
|
final String? priorRemoteId;
|
||||||
|
final String? syncedChecksum;
|
||||||
const LocalAssetEntityData({
|
const LocalAssetEntityData({
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.type,
|
required this.type,
|
||||||
@@ -894,6 +988,8 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
this.latitude,
|
this.latitude,
|
||||||
this.longitude,
|
this.longitude,
|
||||||
required this.playbackStyle,
|
required this.playbackStyle,
|
||||||
|
this.priorRemoteId,
|
||||||
|
this.syncedChecksum,
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||||
@@ -938,6 +1034,12 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
i1.$LocalAssetEntityTable.$converterplaybackStyle.toSql(playbackStyle),
|
i1.$LocalAssetEntityTable.$converterplaybackStyle.toSql(playbackStyle),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!nullToAbsent || priorRemoteId != null) {
|
||||||
|
map['prior_remote_id'] = i0.Variable<String>(priorRemoteId);
|
||||||
|
}
|
||||||
|
if (!nullToAbsent || syncedChecksum != null) {
|
||||||
|
map['synced_checksum'] = i0.Variable<String>(syncedChecksum);
|
||||||
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -967,6 +1069,8 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
playbackStyle: i1.$LocalAssetEntityTable.$converterplaybackStyle.fromJson(
|
playbackStyle: i1.$LocalAssetEntityTable.$converterplaybackStyle.fromJson(
|
||||||
serializer.fromJson<int>(json['playbackStyle']),
|
serializer.fromJson<int>(json['playbackStyle']),
|
||||||
),
|
),
|
||||||
|
priorRemoteId: serializer.fromJson<String?>(json['priorRemoteId']),
|
||||||
|
syncedChecksum: serializer.fromJson<String?>(json['syncedChecksum']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@override
|
@override
|
||||||
@@ -993,6 +1097,8 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
'playbackStyle': serializer.toJson<int>(
|
'playbackStyle': serializer.toJson<int>(
|
||||||
i1.$LocalAssetEntityTable.$converterplaybackStyle.toJson(playbackStyle),
|
i1.$LocalAssetEntityTable.$converterplaybackStyle.toJson(playbackStyle),
|
||||||
),
|
),
|
||||||
|
'priorRemoteId': serializer.toJson<String?>(priorRemoteId),
|
||||||
|
'syncedChecksum': serializer.toJson<String?>(syncedChecksum),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1013,6 +1119,8 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
i0.Value<double?> latitude = const i0.Value.absent(),
|
i0.Value<double?> latitude = const i0.Value.absent(),
|
||||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||||
i2.AssetPlaybackStyle? playbackStyle,
|
i2.AssetPlaybackStyle? playbackStyle,
|
||||||
|
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
|
||||||
|
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
|
||||||
}) => i1.LocalAssetEntityData(
|
}) => i1.LocalAssetEntityData(
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
type: type ?? this.type,
|
type: type ?? this.type,
|
||||||
@@ -1032,6 +1140,12 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
latitude: latitude.present ? latitude.value : this.latitude,
|
latitude: latitude.present ? latitude.value : this.latitude,
|
||||||
longitude: longitude.present ? longitude.value : this.longitude,
|
longitude: longitude.present ? longitude.value : this.longitude,
|
||||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
playbackStyle: playbackStyle ?? this.playbackStyle,
|
||||||
|
priorRemoteId: priorRemoteId.present
|
||||||
|
? priorRemoteId.value
|
||||||
|
: this.priorRemoteId,
|
||||||
|
syncedChecksum: syncedChecksum.present
|
||||||
|
? syncedChecksum.value
|
||||||
|
: this.syncedChecksum,
|
||||||
);
|
);
|
||||||
LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) {
|
LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) {
|
||||||
return LocalAssetEntityData(
|
return LocalAssetEntityData(
|
||||||
@@ -1061,6 +1175,12 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
playbackStyle: data.playbackStyle.present
|
playbackStyle: data.playbackStyle.present
|
||||||
? data.playbackStyle.value
|
? data.playbackStyle.value
|
||||||
: this.playbackStyle,
|
: this.playbackStyle,
|
||||||
|
priorRemoteId: data.priorRemoteId.present
|
||||||
|
? data.priorRemoteId.value
|
||||||
|
: this.priorRemoteId,
|
||||||
|
syncedChecksum: data.syncedChecksum.present
|
||||||
|
? data.syncedChecksum.value
|
||||||
|
: this.syncedChecksum,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1082,7 +1202,9 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
..write('adjustmentTime: $adjustmentTime, ')
|
..write('adjustmentTime: $adjustmentTime, ')
|
||||||
..write('latitude: $latitude, ')
|
..write('latitude: $latitude, ')
|
||||||
..write('longitude: $longitude, ')
|
..write('longitude: $longitude, ')
|
||||||
..write('playbackStyle: $playbackStyle')
|
..write('playbackStyle: $playbackStyle, ')
|
||||||
|
..write('priorRemoteId: $priorRemoteId, ')
|
||||||
|
..write('syncedChecksum: $syncedChecksum')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
@@ -1105,6 +1227,8 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
playbackStyle,
|
playbackStyle,
|
||||||
|
priorRemoteId,
|
||||||
|
syncedChecksum,
|
||||||
);
|
);
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
@@ -1125,7 +1249,9 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
other.adjustmentTime == this.adjustmentTime &&
|
other.adjustmentTime == this.adjustmentTime &&
|
||||||
other.latitude == this.latitude &&
|
other.latitude == this.latitude &&
|
||||||
other.longitude == this.longitude &&
|
other.longitude == this.longitude &&
|
||||||
other.playbackStyle == this.playbackStyle);
|
other.playbackStyle == this.playbackStyle &&
|
||||||
|
other.priorRemoteId == this.priorRemoteId &&
|
||||||
|
other.syncedChecksum == this.syncedChecksum);
|
||||||
}
|
}
|
||||||
|
|
||||||
class LocalAssetEntityCompanion
|
class LocalAssetEntityCompanion
|
||||||
@@ -1146,6 +1272,8 @@ class LocalAssetEntityCompanion
|
|||||||
final i0.Value<double?> latitude;
|
final i0.Value<double?> latitude;
|
||||||
final i0.Value<double?> longitude;
|
final i0.Value<double?> longitude;
|
||||||
final i0.Value<i2.AssetPlaybackStyle> playbackStyle;
|
final i0.Value<i2.AssetPlaybackStyle> playbackStyle;
|
||||||
|
final i0.Value<String?> priorRemoteId;
|
||||||
|
final i0.Value<String?> syncedChecksum;
|
||||||
const LocalAssetEntityCompanion({
|
const LocalAssetEntityCompanion({
|
||||||
this.name = const i0.Value.absent(),
|
this.name = const i0.Value.absent(),
|
||||||
this.type = const i0.Value.absent(),
|
this.type = const i0.Value.absent(),
|
||||||
@@ -1163,6 +1291,8 @@ class LocalAssetEntityCompanion
|
|||||||
this.latitude = const i0.Value.absent(),
|
this.latitude = const i0.Value.absent(),
|
||||||
this.longitude = const i0.Value.absent(),
|
this.longitude = const i0.Value.absent(),
|
||||||
this.playbackStyle = const i0.Value.absent(),
|
this.playbackStyle = const i0.Value.absent(),
|
||||||
|
this.priorRemoteId = const i0.Value.absent(),
|
||||||
|
this.syncedChecksum = const i0.Value.absent(),
|
||||||
});
|
});
|
||||||
LocalAssetEntityCompanion.insert({
|
LocalAssetEntityCompanion.insert({
|
||||||
required String name,
|
required String name,
|
||||||
@@ -1181,6 +1311,8 @@ class LocalAssetEntityCompanion
|
|||||||
this.latitude = const i0.Value.absent(),
|
this.latitude = const i0.Value.absent(),
|
||||||
this.longitude = const i0.Value.absent(),
|
this.longitude = const i0.Value.absent(),
|
||||||
this.playbackStyle = const i0.Value.absent(),
|
this.playbackStyle = const i0.Value.absent(),
|
||||||
|
this.priorRemoteId = const i0.Value.absent(),
|
||||||
|
this.syncedChecksum = const i0.Value.absent(),
|
||||||
}) : name = i0.Value(name),
|
}) : name = i0.Value(name),
|
||||||
type = i0.Value(type),
|
type = i0.Value(type),
|
||||||
id = i0.Value(id);
|
id = i0.Value(id);
|
||||||
@@ -1201,6 +1333,8 @@ class LocalAssetEntityCompanion
|
|||||||
i0.Expression<double>? latitude,
|
i0.Expression<double>? latitude,
|
||||||
i0.Expression<double>? longitude,
|
i0.Expression<double>? longitude,
|
||||||
i0.Expression<int>? playbackStyle,
|
i0.Expression<int>? playbackStyle,
|
||||||
|
i0.Expression<String>? priorRemoteId,
|
||||||
|
i0.Expression<String>? syncedChecksum,
|
||||||
}) {
|
}) {
|
||||||
return i0.RawValuesInsertable({
|
return i0.RawValuesInsertable({
|
||||||
if (name != null) 'name': name,
|
if (name != null) 'name': name,
|
||||||
@@ -1219,6 +1353,8 @@ class LocalAssetEntityCompanion
|
|||||||
if (latitude != null) 'latitude': latitude,
|
if (latitude != null) 'latitude': latitude,
|
||||||
if (longitude != null) 'longitude': longitude,
|
if (longitude != null) 'longitude': longitude,
|
||||||
if (playbackStyle != null) 'playback_style': playbackStyle,
|
if (playbackStyle != null) 'playback_style': playbackStyle,
|
||||||
|
if (priorRemoteId != null) 'prior_remote_id': priorRemoteId,
|
||||||
|
if (syncedChecksum != null) 'synced_checksum': syncedChecksum,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1239,6 +1375,8 @@ class LocalAssetEntityCompanion
|
|||||||
i0.Value<double?>? latitude,
|
i0.Value<double?>? latitude,
|
||||||
i0.Value<double?>? longitude,
|
i0.Value<double?>? longitude,
|
||||||
i0.Value<i2.AssetPlaybackStyle>? playbackStyle,
|
i0.Value<i2.AssetPlaybackStyle>? playbackStyle,
|
||||||
|
i0.Value<String?>? priorRemoteId,
|
||||||
|
i0.Value<String?>? syncedChecksum,
|
||||||
}) {
|
}) {
|
||||||
return i1.LocalAssetEntityCompanion(
|
return i1.LocalAssetEntityCompanion(
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
@@ -1257,6 +1395,8 @@ class LocalAssetEntityCompanion
|
|||||||
latitude: latitude ?? this.latitude,
|
latitude: latitude ?? this.latitude,
|
||||||
longitude: longitude ?? this.longitude,
|
longitude: longitude ?? this.longitude,
|
||||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
playbackStyle: playbackStyle ?? this.playbackStyle,
|
||||||
|
priorRemoteId: priorRemoteId ?? this.priorRemoteId,
|
||||||
|
syncedChecksum: syncedChecksum ?? this.syncedChecksum,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1317,6 +1457,12 @@ class LocalAssetEntityCompanion
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (priorRemoteId.present) {
|
||||||
|
map['prior_remote_id'] = i0.Variable<String>(priorRemoteId.value);
|
||||||
|
}
|
||||||
|
if (syncedChecksum.present) {
|
||||||
|
map['synced_checksum'] = i0.Variable<String>(syncedChecksum.value);
|
||||||
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1338,7 +1484,9 @@ class LocalAssetEntityCompanion
|
|||||||
..write('adjustmentTime: $adjustmentTime, ')
|
..write('adjustmentTime: $adjustmentTime, ')
|
||||||
..write('latitude: $latitude, ')
|
..write('latitude: $latitude, ')
|
||||||
..write('longitude: $longitude, ')
|
..write('longitude: $longitude, ')
|
||||||
..write('playbackStyle: $playbackStyle')
|
..write('playbackStyle: $playbackStyle, ')
|
||||||
|
..write('priorRemoteId: $priorRemoteId, ')
|
||||||
|
..write('syncedChecksum: $syncedChecksum')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,13 @@ AND NOT EXISTS (
|
|||||||
INNER JOIN local_album_entity la on laa.album_id = la.id
|
INNER JOIN local_album_entity la on laa.album_id = la.id
|
||||||
WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded
|
WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded
|
||||||
)
|
)
|
||||||
|
-- iOS edit-in-progress / revert: if this local was already uploaded (its
|
||||||
|
-- prior_remote_id resolves to a live remote), hide the local tile so the remote
|
||||||
|
-- (the edit, or the flipped-back original) is the single source of truth. Kills
|
||||||
|
-- the transient 2-tile flicker and stops a reverted local from re-appearing.
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM remote_asset_entity rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN :user_ids
|
||||||
|
)
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT $limit;
|
LIMIT $limit;
|
||||||
|
|
||||||
@@ -136,6 +143,10 @@ FROM
|
|||||||
INNER JOIN local_album_entity la on laa.album_id = la.id
|
INNER JOIN local_album_entity la on laa.album_id = la.id
|
||||||
WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded
|
WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded
|
||||||
)
|
)
|
||||||
|
-- iOS edit-in-progress / revert: hide a local already represented by a live remote.
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM remote_asset_entity rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN :user_ids
|
||||||
|
)
|
||||||
)
|
)
|
||||||
GROUP BY bucket_date
|
GROUP BY bucket_date
|
||||||
ORDER BY bucket_date DESC;
|
ORDER BY bucket_date DESC;
|
||||||
|
|||||||
+2
-2
@@ -29,7 +29,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
|
|||||||
);
|
);
|
||||||
$arrayStartIndex += generatedlimit.amountOfVariables;
|
$arrayStartIndex += generatedlimit.amountOfVariables;
|
||||||
return customSelect(
|
return customSelect(
|
||||||
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_ms, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style, rae.uploaded_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_ms, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style, NULL AS uploaded_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}',
|
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_ms, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style, rae.uploaded_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_ms, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style, NULL AS uploaded_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) AND NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN ($expandeduserIds)) ORDER BY created_at DESC ${generatedlimit.sql}',
|
||||||
variables: [
|
variables: [
|
||||||
for (var $ in userIds) i0.Variable<String>($),
|
for (var $ in userIds) i0.Variable<String>($),
|
||||||
...generatedlimit.introducedVariables,
|
...generatedlimit.introducedVariables,
|
||||||
@@ -81,7 +81,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
|
|||||||
final expandeduserIds = $expandVar($arrayStartIndex, userIds.length);
|
final expandeduserIds = $expandVar($arrayStartIndex, userIds.length);
|
||||||
$arrayStartIndex += userIds.length;
|
$arrayStartIndex += userIds.length;
|
||||||
return customSelect(
|
return customSelect(
|
||||||
'SELECT COUNT(*) AS asset_count, bucket_date FROM (SELECT CASE WHEN ?1 = 0 THEN COALESCE(STRFTIME(\'%Y-%m-%d\', rae.local_date_time), STRFTIME(\'%Y-%m-%d\', rae.created_at, \'localtime\')) WHEN ?1 = 1 THEN COALESCE(STRFTIME(\'%Y-%m\', rae.local_date_time), STRFTIME(\'%Y-%m\', rae.created_at, \'localtime\')) END AS bucket_date FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', lae.created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', lae.created_at, \'localtime\') END AS bucket_date FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2)) GROUP BY bucket_date ORDER BY bucket_date DESC',
|
'SELECT COUNT(*) AS asset_count, bucket_date FROM (SELECT CASE WHEN ?1 = 0 THEN COALESCE(STRFTIME(\'%Y-%m-%d\', rae.local_date_time), STRFTIME(\'%Y-%m-%d\', rae.created_at, \'localtime\')) WHEN ?1 = 1 THEN COALESCE(STRFTIME(\'%Y-%m\', rae.local_date_time), STRFTIME(\'%Y-%m\', rae.created_at, \'localtime\')) END AS bucket_date FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', lae.created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', lae.created_at, \'localtime\') END AS bucket_date FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) AND NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN ($expandeduserIds))) GROUP BY bucket_date ORDER BY bucket_date DESC',
|
||||||
variables: [
|
variables: [
|
||||||
i0.Variable<int>(groupBy),
|
i0.Variable<int>(groupBy),
|
||||||
for (var $ in userIds) i0.Variable<String>($),
|
for (var $ in userIds) i0.Variable<String>($),
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ class DriftBackupRepository extends DriftDatabaseRepository {
|
|||||||
INNER JOIN main.local_album_entity la on laa.album_id = la.id
|
INNER JOIN main.local_album_entity la on laa.album_id = la.id
|
||||||
WHERE laa.asset_id = lae.id
|
WHERE laa.asset_id = lae.id
|
||||||
AND la.backup_selection = ?3
|
AND la.backup_selection = ?3
|
||||||
);
|
)
|
||||||
|
AND (lae.synced_checksum IS NULL OR lae.synced_checksum != lae.checksum);
|
||||||
''';
|
''';
|
||||||
|
|
||||||
final row = await _db
|
final row = await _db
|
||||||
@@ -104,6 +105,10 @@ class DriftBackupRepository extends DriftDatabaseRepository {
|
|||||||
_db.remoteAssetEntity.checksum.equalsExp(lae.checksum) & _db.remoteAssetEntity.ownerId.equals(userId),
|
_db.remoteAssetEntity.checksum.equalsExp(lae.checksum) & _db.remoteAssetEntity.ownerId.equals(userId),
|
||||||
),
|
),
|
||||||
) &
|
) &
|
||||||
|
// iOS revert: a reverted local hashes fresh (matches nothing remote),
|
||||||
|
// but if it was already reconciled (syncedChecksum == current checksum)
|
||||||
|
// it's handled — don't re-queue it as a fresh upload.
|
||||||
|
(lae.syncedChecksum.isNull() | lae.syncedChecksum.equalsExp(lae.checksum).not()) &
|
||||||
lae.id.isNotInQuery(_getExcludedSubquery()),
|
lae.id.isNotInQuery(_getExcludedSubquery()),
|
||||||
)
|
)
|
||||||
..orderBy([(localAsset) => OrderingTerm.desc(localAsset.createdAt)]);
|
..orderBy([(localAsset) => OrderingTerm.desc(localAsset.createdAt)]);
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ class Drift extends $Drift {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 26;
|
int get schemaVersion => 28;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
@@ -276,6 +276,12 @@ class Drift extends $Drift {
|
|||||||
from25To26: (m, v26) async {
|
from25To26: (m, v26) async {
|
||||||
await m.addColumn(v26.remoteAssetEntity, v26.remoteAssetEntity.uploadedAt);
|
await m.addColumn(v26.remoteAssetEntity, v26.remoteAssetEntity.uploadedAt);
|
||||||
},
|
},
|
||||||
|
from26To27: (m, v27) async {
|
||||||
|
await m.addColumn(v27.localAssetEntity, v27.localAssetEntity.priorRemoteId);
|
||||||
|
},
|
||||||
|
from27To28: (m, v28) async {
|
||||||
|
await m.addColumn(v28.localAssetEntity, v28.localAssetEntity.syncedChecksum);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,12 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> markSynced(String localId, {required String priorRemoteId, required String syncedChecksum}) {
|
||||||
|
return (_db.localAssetEntity.update()..where((e) => e.id.equals(localId))).write(
|
||||||
|
LocalAssetEntityCompanion(priorRemoteId: Value(priorRemoteId), syncedChecksum: Value(syncedChecksum)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> delete(List<String> ids) {
|
Future<void> delete(List<String> ids) {
|
||||||
if (ids.isEmpty) {
|
if (ids.isEmpty) {
|
||||||
return Future.value();
|
return Future.value();
|
||||||
|
|||||||
@@ -1,8 +1,24 @@
|
|||||||
|
import 'package:collection/collection.dart';
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:immich_mobile/constants/constants.dart';
|
||||||
import 'package:immich_mobile/domain/models/stack.model.dart';
|
import 'package:immich_mobile/domain/models/stack.model.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
|
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
|
|
||||||
|
class StackReconcileTarget {
|
||||||
|
final String stackId;
|
||||||
|
final String newPrimaryId;
|
||||||
|
final String localAssetId;
|
||||||
|
final String localAssetChecksum;
|
||||||
|
|
||||||
|
const StackReconcileTarget({
|
||||||
|
required this.stackId,
|
||||||
|
required this.newPrimaryId,
|
||||||
|
required this.localAssetId,
|
||||||
|
required this.localAssetChecksum,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
class DriftStackRepository extends DriftDatabaseRepository {
|
class DriftStackRepository extends DriftDatabaseRepository {
|
||||||
final Drift _db;
|
final Drift _db;
|
||||||
const DriftStackRepository(this._db) : super(_db);
|
const DriftStackRepository(this._db) : super(_db);
|
||||||
@@ -14,6 +30,95 @@ class DriftStackRepository extends DriftDatabaseRepository {
|
|||||||
return stack.toDto();
|
return stack.toDto();
|
||||||
}).get();
|
}).get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per local id, find a stack member whose checksum matches the local's current
|
||||||
|
// checksum but isn't the stack primary — the iOS revert case where the local
|
||||||
|
// hashed back to the base while the primary still points at the edit.
|
||||||
|
Future<List<StackReconcileTarget>> findRevertReconcileTargets(Iterable<String> localAssetIds) async {
|
||||||
|
final ids = localAssetIds.toSet();
|
||||||
|
if (ids.isEmpty) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final targets = <StackReconcileTarget>[];
|
||||||
|
for (final slice in ids.slices(kDriftMaxChunk)) {
|
||||||
|
final placeholders = List.filled(slice.length, '?').join(',');
|
||||||
|
final rows = await _db
|
||||||
|
.customSelect(
|
||||||
|
'''
|
||||||
|
SELECT
|
||||||
|
s.id AS stack_id,
|
||||||
|
member.id AS new_primary,
|
||||||
|
local.id AS local_id,
|
||||||
|
local.checksum AS local_checksum
|
||||||
|
FROM local_asset_entity local
|
||||||
|
INNER JOIN remote_asset_entity prior ON prior.id = local.prior_remote_id
|
||||||
|
INNER JOIN stack_entity s ON s.id = prior.stack_id
|
||||||
|
INNER JOIN remote_asset_entity member
|
||||||
|
ON member.stack_id = s.id
|
||||||
|
AND member.checksum = local.checksum
|
||||||
|
AND member.deleted_at IS NULL
|
||||||
|
WHERE local.id IN ($placeholders)
|
||||||
|
AND s.primary_asset_id != member.id
|
||||||
|
''',
|
||||||
|
variables: slice.map((id) => Variable<String>(id)).toList(),
|
||||||
|
readsFrom: {_db.localAssetEntity, _db.remoteAssetEntity, _db.stackEntity},
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
for (final row in rows) {
|
||||||
|
targets.add(
|
||||||
|
StackReconcileTarget(
|
||||||
|
stackId: row.read<String>('stack_id'),
|
||||||
|
newPrimaryId: row.read<String>('new_primary'),
|
||||||
|
localAssetId: row.read<String>('local_id'),
|
||||||
|
localAssetChecksum: row.read<String>('local_checksum'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stack a remote asset belongs to, if any. Used by the revert path to find
|
||||||
|
// the stack from prior_remote_id when the reverted bytes can't be checksum-matched.
|
||||||
|
Future<String?> findStackIdByRemoteId(String remoteId) async {
|
||||||
|
final row = await _db
|
||||||
|
.customSelect(
|
||||||
|
'SELECT stack_id FROM remote_asset_entity WHERE id = ? AND stack_id IS NOT NULL AND deleted_at IS NULL',
|
||||||
|
variables: [Variable<String>(remoteId)],
|
||||||
|
readsFrom: {_db.remoteAssetEntity},
|
||||||
|
)
|
||||||
|
.getSingleOrNull();
|
||||||
|
return row?.read<String?>('stack_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stack's original base member to flip back to on revert: the earliest-
|
||||||
|
// uploaded member that isn't the (latest-edit) prior. The base is uploaded
|
||||||
|
// before its edits, so oldest uploaded_at = the original.
|
||||||
|
Future<String?> findStackBaseId(String stackId, {required String excludeId}) async {
|
||||||
|
final row = await _db
|
||||||
|
.customSelect(
|
||||||
|
'''
|
||||||
|
SELECT id FROM remote_asset_entity
|
||||||
|
WHERE stack_id = ? AND id != ? AND deleted_at IS NULL
|
||||||
|
ORDER BY uploaded_at IS NULL, uploaded_at ASC, id ASC
|
||||||
|
LIMIT 1
|
||||||
|
''',
|
||||||
|
variables: [Variable<String>(stackId), Variable<String>(excludeId)],
|
||||||
|
readsFrom: {_db.remoteAssetEntity},
|
||||||
|
)
|
||||||
|
.getSingleOrNull();
|
||||||
|
return row?.read<String?>('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistic local primary flip so the timeline updates immediately; the
|
||||||
|
// server's stack-update websocket rewrites it shortly after.
|
||||||
|
Future<void> setPrimary(String stackId, String primaryAssetId) {
|
||||||
|
return (_db.stackEntity.update()..where((e) => e.id.equals(stackId))).write(
|
||||||
|
StackEntityCompanion(primaryAssetId: Value(primaryAssetId)),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension on StackEntityData {
|
extension on StackEntityData {
|
||||||
|
|||||||
+110
-10
@@ -88,6 +88,8 @@ int _deepHash(Object? value) {
|
|||||||
|
|
||||||
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
|
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
|
||||||
|
|
||||||
|
enum EditState { notEdited, edited, unknown }
|
||||||
|
|
||||||
class PlatformAsset {
|
class PlatformAsset {
|
||||||
PlatformAsset({
|
PlatformAsset({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -395,6 +397,55 @@ class CloudIdResult {
|
|||||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class BaseResource {
|
||||||
|
BaseResource({required this.path, required this.sha1, required this.sizeBytes, required this.mimeType});
|
||||||
|
|
||||||
|
String path;
|
||||||
|
|
||||||
|
String sha1;
|
||||||
|
|
||||||
|
int sizeBytes;
|
||||||
|
|
||||||
|
String mimeType;
|
||||||
|
|
||||||
|
List<Object?> _toList() {
|
||||||
|
return <Object?>[path, sha1, sizeBytes, mimeType];
|
||||||
|
}
|
||||||
|
|
||||||
|
Object encode() {
|
||||||
|
return _toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static BaseResource decode(Object result) {
|
||||||
|
result as List<Object?>;
|
||||||
|
return BaseResource(
|
||||||
|
path: result[0]! as String,
|
||||||
|
sha1: result[1]! as String,
|
||||||
|
sizeBytes: result[2]! as int,
|
||||||
|
mimeType: result[3]! as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (other is! BaseResource || other.runtimeType != runtimeType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (identical(this, other)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return _deepEquals(path, other.path) &&
|
||||||
|
_deepEquals(sha1, other.sha1) &&
|
||||||
|
_deepEquals(sizeBytes, other.sizeBytes) &&
|
||||||
|
_deepEquals(mimeType, other.mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
|
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||||
|
}
|
||||||
|
|
||||||
class _PigeonCodec extends StandardMessageCodec {
|
class _PigeonCodec extends StandardMessageCodec {
|
||||||
const _PigeonCodec();
|
const _PigeonCodec();
|
||||||
@override
|
@override
|
||||||
@@ -405,21 +456,27 @@ class _PigeonCodec extends StandardMessageCodec {
|
|||||||
} else if (value is PlatformAssetPlaybackStyle) {
|
} else if (value is PlatformAssetPlaybackStyle) {
|
||||||
buffer.putUint8(129);
|
buffer.putUint8(129);
|
||||||
writeValue(buffer, value.index);
|
writeValue(buffer, value.index);
|
||||||
} else if (value is PlatformAsset) {
|
} else if (value is EditState) {
|
||||||
buffer.putUint8(130);
|
buffer.putUint8(130);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.index);
|
||||||
} else if (value is PlatformAlbum) {
|
} else if (value is PlatformAsset) {
|
||||||
buffer.putUint8(131);
|
buffer.putUint8(131);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else if (value is SyncDelta) {
|
} else if (value is PlatformAlbum) {
|
||||||
buffer.putUint8(132);
|
buffer.putUint8(132);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else if (value is HashResult) {
|
} else if (value is SyncDelta) {
|
||||||
buffer.putUint8(133);
|
buffer.putUint8(133);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else if (value is CloudIdResult) {
|
} else if (value is HashResult) {
|
||||||
buffer.putUint8(134);
|
buffer.putUint8(134);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
|
} else if (value is CloudIdResult) {
|
||||||
|
buffer.putUint8(135);
|
||||||
|
writeValue(buffer, value.encode());
|
||||||
|
} else if (value is BaseResource) {
|
||||||
|
buffer.putUint8(136);
|
||||||
|
writeValue(buffer, value.encode());
|
||||||
} else {
|
} else {
|
||||||
super.writeValue(buffer, value);
|
super.writeValue(buffer, value);
|
||||||
}
|
}
|
||||||
@@ -432,15 +489,20 @@ class _PigeonCodec extends StandardMessageCodec {
|
|||||||
final value = readValue(buffer) as int?;
|
final value = readValue(buffer) as int?;
|
||||||
return value == null ? null : PlatformAssetPlaybackStyle.values[value];
|
return value == null ? null : PlatformAssetPlaybackStyle.values[value];
|
||||||
case 130:
|
case 130:
|
||||||
return PlatformAsset.decode(readValue(buffer)!);
|
final value = readValue(buffer) as int?;
|
||||||
|
return value == null ? null : EditState.values[value];
|
||||||
case 131:
|
case 131:
|
||||||
return PlatformAlbum.decode(readValue(buffer)!);
|
return PlatformAsset.decode(readValue(buffer)!);
|
||||||
case 132:
|
case 132:
|
||||||
return SyncDelta.decode(readValue(buffer)!);
|
return PlatformAlbum.decode(readValue(buffer)!);
|
||||||
case 133:
|
case 133:
|
||||||
return HashResult.decode(readValue(buffer)!);
|
return SyncDelta.decode(readValue(buffer)!);
|
||||||
case 134:
|
case 134:
|
||||||
|
return HashResult.decode(readValue(buffer)!);
|
||||||
|
case 135:
|
||||||
return CloudIdResult.decode(readValue(buffer)!);
|
return CloudIdResult.decode(readValue(buffer)!);
|
||||||
|
case 136:
|
||||||
|
return BaseResource.decode(readValue(buffer)!);
|
||||||
default:
|
default:
|
||||||
return super.readValueOfType(type, buffer);
|
return super.readValueOfType(type, buffer);
|
||||||
}
|
}
|
||||||
@@ -672,4 +734,42 @@ class NativeSyncApi {
|
|||||||
);
|
);
|
||||||
return (pigeonVar_replyValue! as List<Object?>).cast<CloudIdResult>();
|
return (pigeonVar_replyValue! as List<Object?>).cast<CloudIdResult>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<BaseResource?> getBaseResource(String assetId, {bool allowNetworkAccess = false}) async {
|
||||||
|
final pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource$pigeonVar_messageChannelSuffix';
|
||||||
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetId, allowNetworkAccess]);
|
||||||
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
|
pigeonVar_replyList,
|
||||||
|
pigeonVar_channelName,
|
||||||
|
isNullValid: true,
|
||||||
|
);
|
||||||
|
return pigeonVar_replyValue as BaseResource?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<EditState> getEditState(String assetId, {bool allowNetworkAccess = false}) async {
|
||||||
|
final pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState$pigeonVar_messageChannelSuffix';
|
||||||
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetId, allowNetworkAccess]);
|
||||||
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
|
pigeonVar_replyList,
|
||||||
|
pigeonVar_channelName,
|
||||||
|
isNullValid: false,
|
||||||
|
);
|
||||||
|
return pigeonVar_replyValue! as EditState;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
-19
@@ -309,23 +309,4 @@ class NetworkApi {
|
|||||||
|
|
||||||
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
|
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String> getAppGroupId() async {
|
|
||||||
final pigeonVar_channelName =
|
|
||||||
'dev.flutter.pigeon.immich_mobile.NetworkApi.getAppGroupId$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 String;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
|
||||||
import 'package:immich_mobile/domain/services/hash.service.dart';
|
import 'package:immich_mobile/domain/services/hash.service.dart';
|
||||||
import 'package:immich_mobile/domain/services/local_sync.service.dart';
|
import 'package:immich_mobile/domain/services/local_sync.service.dart';
|
||||||
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
|
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
|
||||||
@@ -11,7 +12,9 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
|||||||
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/stack.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||||
|
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||||
|
|
||||||
final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider)));
|
final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider)));
|
||||||
@@ -45,11 +48,22 @@ final localSyncServiceProvider = Provider(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final editRevertServiceProvider = Provider(
|
||||||
|
(ref) => EditRevertService(
|
||||||
|
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||||
|
stackRepository: ref.watch(driftStackProvider),
|
||||||
|
localAssetRepository: ref.watch(localAssetRepository),
|
||||||
|
assetApiRepository: ref.watch(assetApiRepositoryProvider),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
final hashServiceProvider = Provider(
|
final hashServiceProvider = Provider(
|
||||||
(ref) => HashService(
|
(ref) => HashService(
|
||||||
localAlbumRepository: ref.watch(localAlbumRepository),
|
localAlbumRepository: ref.watch(localAlbumRepository),
|
||||||
localAssetRepository: ref.watch(localAssetRepository),
|
localAssetRepository: ref.watch(localAssetRepository),
|
||||||
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||||
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||||
|
stackRepository: ref.watch(driftStackProvider),
|
||||||
|
assetApiRepository: ref.watch(assetApiRepositoryProvider),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
|||||||
socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2);
|
socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2);
|
||||||
socket.on('on_config_update', _handleOnConfigUpdate);
|
socket.on('on_config_update', _handleOnConfigUpdate);
|
||||||
socket.on('on_new_release', _handleReleaseUpdates);
|
socket.on('on_new_release', _handleReleaseUpdates);
|
||||||
|
socket.on('on_asset_stack_update', _handleAssetStackUpdate);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dPrint(() => "[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
|
dPrint(() => "[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
|
||||||
}
|
}
|
||||||
@@ -188,6 +189,13 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
|||||||
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV2(data));
|
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV2(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Server stacked/restacked assets (e.g. an edit stacked onto its original).
|
||||||
|
// Pull a fresh remote sync so the stack_entity lands and the timeline shows
|
||||||
|
// the stacked primary instead of briefly hiding the asset.
|
||||||
|
void _handleAssetStackUpdate(dynamic _) {
|
||||||
|
unawaited(_ref.read(backgroundSyncProvider).runFreshRemoteSync());
|
||||||
|
}
|
||||||
|
|
||||||
void _processBatchedAssetUploadReadyV1() {
|
void _processBatchedAssetUploadReadyV1() {
|
||||||
if (_batchedAssetUploadReady.isEmpty) {
|
if (_batchedAssetUploadReady.isEmpty) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ class AssetApiRepository extends ApiRepository {
|
|||||||
return _stacksApi.deleteStacks(BulkIdsDto(ids: ids));
|
return _stacksApi.deleteStacks(BulkIdsDto(ids: ids));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setStackPrimary(String stackId, String primaryAssetId) async {
|
||||||
|
await _stacksApi.updateStack(stackId, StackUpdateDto(primaryAssetId: primaryAssetId));
|
||||||
|
}
|
||||||
|
|
||||||
Future<Response> downloadAsset(String id, {required bool edited}) {
|
Future<Response> downloadAsset(String id, {required bool edited}) {
|
||||||
return _api.downloadAssetWithHttpInfo(id, edited: edited);
|
return _api.downloadAssetWithHttpInfo(id, edited: edited);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ class UploadRepository {
|
|||||||
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
||||||
taskProgressCallback: (update) => onTaskProgress?.call(update),
|
taskProgressCallback: (update) => onTaskProgress?.call(update),
|
||||||
);
|
);
|
||||||
|
FileDownloader().registerCallbacks(
|
||||||
|
group: kBackupEditPairGroup,
|
||||||
|
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
||||||
|
taskProgressCallback: (update) => onTaskProgress?.call(update),
|
||||||
|
);
|
||||||
FileDownloader().registerCallbacks(
|
FileDownloader().registerCallbacks(
|
||||||
group: kManualUploadGroup,
|
group: kManualUploadGroup,
|
||||||
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'package:home_widget/home_widget.dart';
|
import 'package:home_widget/home_widget.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
|
||||||
|
|
||||||
final widgetRepositoryProvider = Provider((_) => const WidgetRepository());
|
final widgetRepositoryProvider = Provider((_) => const WidgetRepository());
|
||||||
|
|
||||||
@@ -15,7 +14,7 @@ class WidgetRepository {
|
|||||||
await HomeWidget.updateWidget(iOSName: iosName, qualifiedAndroidName: androidName);
|
await HomeWidget.updateWidget(iOSName: iosName, qualifiedAndroidName: androidName);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setAppGroupId() async {
|
Future<void> setAppGroupId(String appGroupId) async {
|
||||||
await HomeWidget.setAppGroupId(await networkApi.getAppGroupId());
|
await HomeWidget.setAppGroupId(appGroupId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,17 +9,22 @@ import 'package:immich_mobile/constants/constants.dart';
|
|||||||
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
|
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||||
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
|
import 'package:immich_mobile/services/edit_pair.dart';
|
||||||
import 'package:immich_mobile/utils/debug_print.dart';
|
import 'package:immich_mobile/utils/debug_print.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
@@ -31,6 +36,8 @@ final backgroundUploadServiceProvider = Provider((ref) {
|
|||||||
ref.watch(localAssetRepository),
|
ref.watch(localAssetRepository),
|
||||||
ref.watch(backupRepositoryProvider),
|
ref.watch(backupRepositoryProvider),
|
||||||
ref.watch(assetMediaRepositoryProvider),
|
ref.watch(assetMediaRepositoryProvider),
|
||||||
|
ref.watch(nativeSyncApiProvider),
|
||||||
|
ref.watch(editRevertServiceProvider),
|
||||||
);
|
);
|
||||||
|
|
||||||
ref.onDispose(service.dispose);
|
ref.onDispose(service.dispose);
|
||||||
@@ -43,13 +50,35 @@ class UploadTaskMetadata {
|
|||||||
final bool isLivePhotos;
|
final bool isLivePhotos;
|
||||||
final String livePhotoVideoId;
|
final String livePhotoVideoId;
|
||||||
|
|
||||||
const UploadTaskMetadata({required this.localAssetId, required this.isLivePhotos, required this.livePhotoVideoId});
|
// Marks the base upload of an edit pair. On completion the chained edit
|
||||||
|
// upload is enqueued with stackParentId = this base's remote id.
|
||||||
|
final bool isEditPair;
|
||||||
|
|
||||||
UploadTaskMetadata copyWith({String? localAssetId, bool? isLivePhotos, String? livePhotoVideoId}) {
|
// Path of the native temp file backing this task (the edit base), so it can
|
||||||
|
// be cleaned up on terminal status.
|
||||||
|
final String basePath;
|
||||||
|
|
||||||
|
const UploadTaskMetadata({
|
||||||
|
required this.localAssetId,
|
||||||
|
required this.isLivePhotos,
|
||||||
|
required this.livePhotoVideoId,
|
||||||
|
this.isEditPair = false,
|
||||||
|
this.basePath = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
UploadTaskMetadata copyWith({
|
||||||
|
String? localAssetId,
|
||||||
|
bool? isLivePhotos,
|
||||||
|
String? livePhotoVideoId,
|
||||||
|
bool? isEditPair,
|
||||||
|
String? basePath,
|
||||||
|
}) {
|
||||||
return UploadTaskMetadata(
|
return UploadTaskMetadata(
|
||||||
localAssetId: localAssetId ?? this.localAssetId,
|
localAssetId: localAssetId ?? this.localAssetId,
|
||||||
isLivePhotos: isLivePhotos ?? this.isLivePhotos,
|
isLivePhotos: isLivePhotos ?? this.isLivePhotos,
|
||||||
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
|
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
|
||||||
|
isEditPair: isEditPair ?? this.isEditPair,
|
||||||
|
basePath: basePath ?? this.basePath,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +87,8 @@ class UploadTaskMetadata {
|
|||||||
'localAssetId': localAssetId,
|
'localAssetId': localAssetId,
|
||||||
'isLivePhotos': isLivePhotos,
|
'isLivePhotos': isLivePhotos,
|
||||||
'livePhotoVideoId': livePhotoVideoId,
|
'livePhotoVideoId': livePhotoVideoId,
|
||||||
|
'isEditPair': isEditPair,
|
||||||
|
'basePath': basePath,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +97,8 @@ class UploadTaskMetadata {
|
|||||||
localAssetId: map['localAssetId'] as String,
|
localAssetId: map['localAssetId'] as String,
|
||||||
isLivePhotos: map['isLivePhotos'] as bool,
|
isLivePhotos: map['isLivePhotos'] as bool,
|
||||||
livePhotoVideoId: map['livePhotoVideoId'] as String,
|
livePhotoVideoId: map['livePhotoVideoId'] as String,
|
||||||
|
isEditPair: (map['isEditPair'] as bool?) ?? false,
|
||||||
|
basePath: (map['basePath'] as String?) ?? '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +109,7 @@ class UploadTaskMetadata {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() =>
|
String toString() =>
|
||||||
'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId)';
|
'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId, isEditPair: $isEditPair, basePath: $basePath)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(covariant UploadTaskMetadata other) {
|
bool operator ==(covariant UploadTaskMetadata other) {
|
||||||
@@ -86,11 +119,18 @@ class UploadTaskMetadata {
|
|||||||
|
|
||||||
return other.localAssetId == localAssetId &&
|
return other.localAssetId == localAssetId &&
|
||||||
other.isLivePhotos == isLivePhotos &&
|
other.isLivePhotos == isLivePhotos &&
|
||||||
other.livePhotoVideoId == livePhotoVideoId;
|
other.livePhotoVideoId == livePhotoVideoId &&
|
||||||
|
other.isEditPair == isEditPair &&
|
||||||
|
other.basePath == basePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => localAssetId.hashCode ^ isLivePhotos.hashCode ^ livePhotoVideoId.hashCode;
|
int get hashCode =>
|
||||||
|
localAssetId.hashCode ^
|
||||||
|
isLivePhotos.hashCode ^
|
||||||
|
livePhotoVideoId.hashCode ^
|
||||||
|
isEditPair.hashCode ^
|
||||||
|
basePath.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Service for handling background uploads using iOS URLSession (background_downloader)
|
/// Service for handling background uploads using iOS URLSession (background_downloader)
|
||||||
@@ -104,6 +144,8 @@ class BackgroundUploadService {
|
|||||||
this._localAssetRepository,
|
this._localAssetRepository,
|
||||||
this._backupRepository,
|
this._backupRepository,
|
||||||
this._assetMediaRepository,
|
this._assetMediaRepository,
|
||||||
|
this._nativeSyncApi,
|
||||||
|
this._editRevertService,
|
||||||
) {
|
) {
|
||||||
_uploadRepository.onUploadStatus = _onUploadCallback;
|
_uploadRepository.onUploadStatus = _onUploadCallback;
|
||||||
_uploadRepository.onTaskProgress = _onTaskProgressCallback;
|
_uploadRepository.onTaskProgress = _onTaskProgressCallback;
|
||||||
@@ -114,6 +156,8 @@ class BackgroundUploadService {
|
|||||||
final DriftLocalAssetRepository _localAssetRepository;
|
final DriftLocalAssetRepository _localAssetRepository;
|
||||||
final DriftBackupRepository _backupRepository;
|
final DriftBackupRepository _backupRepository;
|
||||||
final AssetMediaRepository _assetMediaRepository;
|
final AssetMediaRepository _assetMediaRepository;
|
||||||
|
final NativeSyncApi _nativeSyncApi;
|
||||||
|
final EditRevertService _editRevertService;
|
||||||
final Logger _logger = Logger('BackgroundUploadService');
|
final Logger _logger = Logger('BackgroundUploadService');
|
||||||
|
|
||||||
final StreamController<TaskStatusUpdate> _taskStatusController = StreamController<TaskStatusUpdate>.broadcast();
|
final StreamController<TaskStatusUpdate> _taskStatusController = StreamController<TaskStatusUpdate>.broadcast();
|
||||||
@@ -205,9 +249,20 @@ class BackgroundUploadService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleTaskStatusUpdate(TaskStatusUpdate update) async {
|
void _handleTaskStatusUpdate(TaskStatusUpdate update) async {
|
||||||
|
UploadTaskMetadata? metadata;
|
||||||
|
if (update.task.metaData.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
metadata = UploadTaskMetadata.fromJson(update.task.metaData);
|
||||||
|
} catch (_) {
|
||||||
|
metadata = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch (update.status) {
|
switch (update.status) {
|
||||||
case TaskStatus.complete:
|
case TaskStatus.complete:
|
||||||
unawaited(_handleLivePhoto(update));
|
unawaited(_handleLivePhoto(update, metadata));
|
||||||
|
unawaited(handleEditPair(update, metadata));
|
||||||
|
unawaited(recordPriorRemoteIdOnSuccess(update, metadata));
|
||||||
|
|
||||||
if (CurrentPlatform.isIOS) {
|
if (CurrentPlatform.isIOS) {
|
||||||
try {
|
try {
|
||||||
@@ -220,19 +275,20 @@ class BackgroundUploadService {
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case TaskStatus.failed:
|
||||||
|
case TaskStatus.canceled:
|
||||||
|
case TaskStatus.notFound:
|
||||||
|
unawaited(_cleanupTempResourceOnFailure(metadata));
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleLivePhoto(TaskStatusUpdate update) async {
|
Future<void> _handleLivePhoto(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
|
||||||
try {
|
try {
|
||||||
if (update.task.metaData.isEmpty || update.task.metaData == '') {
|
if (metadata == null || !metadata.isLivePhotos) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final metadata = UploadTaskMetadata.fromJson(update.task.metaData);
|
|
||||||
if (!metadata.isLivePhotos) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +314,143 @@ class BackgroundUploadService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// When an edit-pair base upload finishes, enqueue the edit on top of it
|
||||||
|
/// (stackParentId = the base's new remote id).
|
||||||
|
@visibleForTesting
|
||||||
|
Future<void> handleEditPair(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
|
||||||
|
try {
|
||||||
|
if (metadata == null || !metadata.isEditPair) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (metadata.basePath.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
await File(metadata.basePath).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
final baseRemoteId = _remoteIdFromResponse(update);
|
||||||
|
if (baseRemoteId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final localAsset = await _localAssetRepository.getById(metadata.localAssetId);
|
||||||
|
if (localAsset == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final editTask = await getEditUploadTask(localAsset, baseRemoteId);
|
||||||
|
if (editTask != null) {
|
||||||
|
await enqueueTasks([editTask]);
|
||||||
|
}
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
dPrint(() => "Error handling edit pair task: $error $stackTrace");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves the uploaded remote id as the asset's priorRemoteId so a later edit
|
||||||
|
/// stacks onto it. Skipped for edit-pair base uploads; the chained edit records it.
|
||||||
|
@visibleForTesting
|
||||||
|
Future<void> recordPriorRemoteIdOnSuccess(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
|
||||||
|
try {
|
||||||
|
if (metadata == null || metadata.isEditPair || metadata.localAssetId.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final remoteId = _remoteIdFromResponse(update);
|
||||||
|
if (remoteId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final localAsset = await _localAssetRepository.getById(metadata.localAssetId);
|
||||||
|
await _localAssetRepository.markSynced(
|
||||||
|
metadata.localAssetId,
|
||||||
|
priorRemoteId: remoteId,
|
||||||
|
syncedChecksum: localAsset?.checksum ?? '',
|
||||||
|
);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
dPrint(() => "Error recording priorRemoteId: $error $stackTrace");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cleanupTempResourceOnFailure(UploadTaskMetadata? metadata) async {
|
||||||
|
if (metadata == null || metadata.basePath.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await File(metadata.basePath).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The new asset's remote id from an upload's response body, or null if the
|
||||||
|
/// body is missing/malformed.
|
||||||
|
String? _remoteIdFromResponse(TaskStatusUpdate update) {
|
||||||
|
final body = update.responseBody;
|
||||||
|
if (body == null || body.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return jsonDecode(body)['id'] as String?;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<UploadTask> _buildBaseUploadTask(LocalAsset asset, BaseResource base) async {
|
||||||
|
final metadata = UploadTaskMetadata(
|
||||||
|
localAssetId: asset.id,
|
||||||
|
isLivePhotos: false,
|
||||||
|
livePhotoVideoId: '',
|
||||||
|
isEditPair: true,
|
||||||
|
basePath: base.path,
|
||||||
|
).toJson();
|
||||||
|
|
||||||
|
// The base is the unedited original (no adjustmentTime); the `_base`
|
||||||
|
// deviceAssetId keeps it distinct from the chained edit task.
|
||||||
|
return buildUploadTask(
|
||||||
|
File(base.path),
|
||||||
|
createdAt: asset.createdAt,
|
||||||
|
modifiedAt: asset.updatedAt,
|
||||||
|
originalFileName: p.setExtension(asset.name, p.extension(base.path)),
|
||||||
|
deviceAssetId: '${asset.id}_base',
|
||||||
|
metadata: metadata,
|
||||||
|
group: kBackupGroup,
|
||||||
|
isFavorite: asset.isFavorite,
|
||||||
|
requiresWiFi: _shouldRequireWiFi(asset),
|
||||||
|
cloudId: asset.cloudId,
|
||||||
|
latitude: asset.latitude?.toString(),
|
||||||
|
longitude: asset.longitude?.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
Future<UploadTask?> getEditUploadTask(LocalAsset asset, String stackParentId) async {
|
||||||
|
final entity = await _storageRepository.getAssetEntityForAsset(asset);
|
||||||
|
if (entity == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final file = await _storageRepository.getFileForAsset(asset.id);
|
||||||
|
if (file == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final fields = {'stackParentId': stackParentId};
|
||||||
|
final originalFileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
||||||
|
final metadata = UploadTaskMetadata(localAssetId: asset.id, isLivePhotos: false, livePhotoVideoId: '').toJson();
|
||||||
|
|
||||||
|
return buildUploadTask(
|
||||||
|
file,
|
||||||
|
createdAt: asset.createdAt,
|
||||||
|
modifiedAt: asset.updatedAt,
|
||||||
|
originalFileName: originalFileName,
|
||||||
|
deviceAssetId: asset.id,
|
||||||
|
metadata: metadata,
|
||||||
|
fields: fields,
|
||||||
|
group: kBackupEditPairGroup,
|
||||||
|
priority: 0,
|
||||||
|
isFavorite: asset.isFavorite,
|
||||||
|
requiresWiFi: _shouldRequireWiFi(asset),
|
||||||
|
cloudId: asset.cloudId,
|
||||||
|
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
|
||||||
|
latitude: asset.latitude?.toString(),
|
||||||
|
longitude: asset.longitude?.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
Future<UploadTask?> getUploadTask(LocalAsset asset, {String group = kBackupGroup, int? priority}) async {
|
Future<UploadTask?> getUploadTask(LocalAsset asset, {String group = kBackupGroup, int? priority}) async {
|
||||||
final entity = await _storageRepository.getAssetEntityForAsset(asset);
|
final entity = await _storageRepository.getAssetEntityForAsset(asset);
|
||||||
@@ -266,6 +459,24 @@ class BackgroundUploadService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// iOS edit pair: stack a user edit onto its original. resolveEditPair decides
|
||||||
|
// whether to reuse a prior upload or upload the base first. Live photos skip this.
|
||||||
|
if (!entity.isLivePhoto && CurrentPlatform.isIOS) {
|
||||||
|
// A reverted edit flips the stack back to the original and skips the upload.
|
||||||
|
if (asset.priorRemoteId != null && await _editRevertService.tryHandleRevert(asset)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final plan = await resolveEditPair(_nativeSyncApi, asset, log: _logger);
|
||||||
|
switch (plan) {
|
||||||
|
case UploadBaseFirst(:final base):
|
||||||
|
return _buildBaseUploadTask(asset, base);
|
||||||
|
case AbsorbIntoPrior(:final parentId):
|
||||||
|
return getEditUploadTask(asset, parentId);
|
||||||
|
case NoEditPair():
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
File? file;
|
File? file;
|
||||||
|
|
||||||
/// iOS LivePhoto has two files: a photo and a video.
|
/// iOS LivePhoto has two files: a photo and a video.
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
/// What to do with an edited iOS photo when backing it up.
|
||||||
|
sealed class EditPairPlan {
|
||||||
|
const EditPairPlan();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Not something we stack: not edited, identical bytes, or couldn't read it.
|
||||||
|
class NoEditPair extends EditPairPlan {
|
||||||
|
const NoEditPair();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Already uploaded before; stack the edit onto that remote id.
|
||||||
|
class AbsorbIntoPrior extends EditPairPlan {
|
||||||
|
final String parentId;
|
||||||
|
const AbsorbIntoPrior(this.parentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload the original first; [base] is its temp file.
|
||||||
|
class UploadBaseFirst extends EditPairPlan {
|
||||||
|
final BaseResource base;
|
||||||
|
const UploadBaseFirst(this.base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Works out how an edited photo should stack: reuse a prior upload, upload the
|
||||||
|
/// original first, or do nothing. Shared by the foreground and background upload
|
||||||
|
/// paths. The caller already checked it's iOS and not a live photo.
|
||||||
|
Future<EditPairPlan> resolveEditPair(NativeSyncApi nativeSyncApi, LocalAsset asset, {Logger? log}) async {
|
||||||
|
if (asset.priorRemoteId != null) {
|
||||||
|
return AbsorbIntoPrior(asset.priorRemoteId!);
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseResource? base;
|
||||||
|
try {
|
||||||
|
base = await nativeSyncApi.getBaseResource(asset.id, allowNetworkAccess: true);
|
||||||
|
} catch (error, stack) {
|
||||||
|
log?.warning(() => "Failed to read base resource for ${asset.id}", error, stack);
|
||||||
|
return const NoEditPair();
|
||||||
|
}
|
||||||
|
if (base == null) {
|
||||||
|
return const NoEditPair();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identical bytes (e.g. auto-HDR), nothing real to stack. Drop the temp copy.
|
||||||
|
if (base.sha1 == asset.checksum) {
|
||||||
|
try {
|
||||||
|
await File(base.path).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
return const NoEditPair();
|
||||||
|
}
|
||||||
|
|
||||||
|
return UploadBaseFirst(base);
|
||||||
|
}
|
||||||
@@ -6,18 +6,24 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
|
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
|
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||||
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||||
|
import 'package:immich_mobile/services/edit_pair.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
||||||
@@ -39,6 +45,9 @@ final foregroundUploadServiceProvider = Provider((ref) {
|
|||||||
ref.watch(backupRepositoryProvider),
|
ref.watch(backupRepositoryProvider),
|
||||||
ref.watch(connectivityApiProvider),
|
ref.watch(connectivityApiProvider),
|
||||||
ref.watch(assetMediaRepositoryProvider),
|
ref.watch(assetMediaRepositoryProvider),
|
||||||
|
ref.watch(nativeSyncApiProvider),
|
||||||
|
ref.watch(localAssetRepository),
|
||||||
|
ref.watch(editRevertServiceProvider),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,6 +63,9 @@ class ForegroundUploadService {
|
|||||||
this._backupRepository,
|
this._backupRepository,
|
||||||
this._connectivityApi,
|
this._connectivityApi,
|
||||||
this._assetMediaRepository,
|
this._assetMediaRepository,
|
||||||
|
this._nativeSyncApi,
|
||||||
|
this._localAssetRepository,
|
||||||
|
this._editRevertService,
|
||||||
);
|
);
|
||||||
|
|
||||||
final UploadRepository _uploadRepository;
|
final UploadRepository _uploadRepository;
|
||||||
@@ -61,6 +73,9 @@ class ForegroundUploadService {
|
|||||||
final DriftBackupRepository _backupRepository;
|
final DriftBackupRepository _backupRepository;
|
||||||
final ConnectivityApi _connectivityApi;
|
final ConnectivityApi _connectivityApi;
|
||||||
final AssetMediaRepository _assetMediaRepository;
|
final AssetMediaRepository _assetMediaRepository;
|
||||||
|
final NativeSyncApi _nativeSyncApi;
|
||||||
|
final DriftLocalAssetRepository _localAssetRepository;
|
||||||
|
final EditRevertService _editRevertService;
|
||||||
final Logger _logger = Logger('ForegroundUploadService');
|
final Logger _logger = Logger('ForegroundUploadService');
|
||||||
|
|
||||||
bool shouldAbortUpload = false;
|
bool shouldAbortUpload = false;
|
||||||
@@ -250,6 +265,12 @@ class ForegroundUploadService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A reverted iOS edit flips the stack back to the original and skips the upload.
|
||||||
|
if (CurrentPlatform.isIOS && asset.priorRemoteId != null && await _editRevertService.tryHandleRevert(asset)) {
|
||||||
|
callbacks.onSuccess?.call(asset.localId!, asset.priorRemoteId!);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final isAvailableLocally = await _storageRepository.isAssetAvailableLocally(asset.id);
|
final isAvailableLocally = await _storageRepository.isAssetAvailableLocally(asset.id);
|
||||||
|
|
||||||
if (!isAvailableLocally && CurrentPlatform.isIOS) {
|
if (!isAvailableLocally && CurrentPlatform.isIOS) {
|
||||||
@@ -371,6 +392,11 @@ class ForegroundUploadService {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final stackParentId = await _maybeUploadBaseResource(asset, Map.of(fields), cancelToken);
|
||||||
|
if (stackParentId != null) {
|
||||||
|
fields['stackParentId'] = stackParentId;
|
||||||
|
}
|
||||||
|
|
||||||
final onProgress = callbacks.onProgress;
|
final onProgress = callbacks.onProgress;
|
||||||
final result = await _uploadRepository.uploadFile(
|
final result = await _uploadRepository.uploadFile(
|
||||||
file: file,
|
file: file,
|
||||||
@@ -384,6 +410,13 @@ class ForegroundUploadService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result.isSuccess && result.remoteAssetId != null) {
|
if (result.isSuccess && result.remoteAssetId != null) {
|
||||||
|
unawaited(
|
||||||
|
_localAssetRepository.markSynced(
|
||||||
|
asset.localId!,
|
||||||
|
priorRemoteId: result.remoteAssetId!,
|
||||||
|
syncedChecksum: asset.checksum ?? '',
|
||||||
|
),
|
||||||
|
);
|
||||||
callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!);
|
callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!);
|
||||||
} else if (result.isCancelled) {
|
} else if (result.isCancelled) {
|
||||||
_logger.warning(() => "Backup was cancelled by the user");
|
_logger.warning(() => "Backup was cancelled by the user");
|
||||||
@@ -415,6 +448,43 @@ class ForegroundUploadService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// For an edited iOS photo, uploads the original camera bytes and returns its
|
||||||
|
/// remote id to use as the edit's stackParentId. Returns null for non-edits.
|
||||||
|
Future<String?> _maybeUploadBaseResource(
|
||||||
|
LocalAsset asset,
|
||||||
|
Map<String, String> baseFields,
|
||||||
|
Completer<void>? cancelToken,
|
||||||
|
) async {
|
||||||
|
if (!CurrentPlatform.isIOS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final plan = await resolveEditPair(_nativeSyncApi, asset, log: _logger);
|
||||||
|
switch (plan) {
|
||||||
|
case NoEditPair():
|
||||||
|
return null;
|
||||||
|
case AbsorbIntoPrior(:final parentId):
|
||||||
|
return parentId;
|
||||||
|
case UploadBaseFirst(:final base):
|
||||||
|
final baseFile = File(base.path);
|
||||||
|
try {
|
||||||
|
final baseName = p.setExtension(asset.name, p.extension(base.path));
|
||||||
|
final result = await _uploadRepository.uploadFile(
|
||||||
|
file: baseFile,
|
||||||
|
originalFileName: baseName,
|
||||||
|
fields: baseFields,
|
||||||
|
cancelToken: cancelToken,
|
||||||
|
logContext: 'baseResource[${asset.localId}]',
|
||||||
|
);
|
||||||
|
return result.isSuccess ? result.remoteAssetId : null;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await baseFile.delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<UploadResult> _uploadSingleFile(
|
Future<UploadResult> _uploadSingleFile(
|
||||||
File file, {
|
File file, {
|
||||||
required String deviceAssetId,
|
required String deviceAssetId,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class WidgetService {
|
|||||||
const WidgetService(this._repository);
|
const WidgetService(this._repository);
|
||||||
|
|
||||||
Future<void> writeCredentials(String serverURL, String sessionKey, String? customHeaders) async {
|
Future<void> writeCredentials(String serverURL, String sessionKey, String? customHeaders) async {
|
||||||
await _repository.setAppGroupId();
|
await _repository.setAppGroupId(appShareGroupId);
|
||||||
await _repository.saveData(kWidgetServerEndpoint, serverURL);
|
await _repository.saveData(kWidgetServerEndpoint, serverURL);
|
||||||
await _repository.saveData(kWidgetAuthToken, sessionKey);
|
await _repository.saveData(kWidgetAuthToken, sessionKey);
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ class WidgetService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clearCredentials() async {
|
Future<void> clearCredentials() async {
|
||||||
await _repository.setAppGroupId();
|
await _repository.setAppGroupId(appShareGroupId);
|
||||||
await _repository.saveData(kWidgetServerEndpoint, "");
|
await _repository.saveData(kWidgetServerEndpoint, "");
|
||||||
await _repository.saveData(kWidgetAuthToken, "");
|
await _repository.saveData(kWidgetAuthToken, "");
|
||||||
await _repository.saveData(kWidgetCustomHeaders, "");
|
await _repository.saveData(kWidgetCustomHeaders, "");
|
||||||
|
|||||||
Generated
+13
-3
@@ -1252,8 +1252,11 @@ class AssetsApi {
|
|||||||
/// * [MultipartFile] sidecarData:
|
/// * [MultipartFile] sidecarData:
|
||||||
/// Sidecar file data
|
/// Sidecar file data
|
||||||
///
|
///
|
||||||
|
/// * [String] stackParentId:
|
||||||
|
/// Stack this asset onto the parent asset, with the new asset as the stack primary
|
||||||
|
///
|
||||||
/// * [AssetVisibility] visibility:
|
/// * [AssetVisibility] visibility:
|
||||||
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async {
|
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, String? stackParentId, AssetVisibility? visibility, }) async {
|
||||||
// ignore: prefer_const_declarations
|
// ignore: prefer_const_declarations
|
||||||
final apiPath = r'/assets';
|
final apiPath = r'/assets';
|
||||||
|
|
||||||
@@ -1317,6 +1320,10 @@ class AssetsApi {
|
|||||||
mp.fields[r'sidecarData'] = sidecarData.field;
|
mp.fields[r'sidecarData'] = sidecarData.field;
|
||||||
mp.files.add(sidecarData);
|
mp.files.add(sidecarData);
|
||||||
}
|
}
|
||||||
|
if (stackParentId != null) {
|
||||||
|
hasFields = true;
|
||||||
|
mp.fields[r'stackParentId'] = parameterToString(stackParentId);
|
||||||
|
}
|
||||||
if (visibility != null) {
|
if (visibility != null) {
|
||||||
hasFields = true;
|
hasFields = true;
|
||||||
mp.fields[r'visibility'] = parameterToString(visibility);
|
mp.fields[r'visibility'] = parameterToString(visibility);
|
||||||
@@ -1376,9 +1383,12 @@ class AssetsApi {
|
|||||||
/// * [MultipartFile] sidecarData:
|
/// * [MultipartFile] sidecarData:
|
||||||
/// Sidecar file data
|
/// Sidecar file data
|
||||||
///
|
///
|
||||||
|
/// * [String] stackParentId:
|
||||||
|
/// Stack this asset onto the parent asset, with the new asset as the stack primary
|
||||||
|
///
|
||||||
/// * [AssetVisibility] visibility:
|
/// * [AssetVisibility] visibility:
|
||||||
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async {
|
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, String? stackParentId, AssetVisibility? visibility, }) async {
|
||||||
final response = await uploadAssetWithHttpInfo(assetData, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, metadata: metadata, sidecarData: sidecarData, visibility: visibility, );
|
final response = await uploadAssetWithHttpInfo(assetData, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, metadata: metadata, sidecarData: sidecarData, stackParentId: stackParentId, visibility: visibility, );
|
||||||
if (response.statusCode >= HttpStatus.badRequest) {
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: async
|
name: async
|
||||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.13.1"
|
version: "2.13.0"
|
||||||
boolean_selector:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -103,10 +103,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.17.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -124,10 +124,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: source_span
|
name: source_span
|
||||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.2"
|
version: "1.10.1"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -164,10 +164,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.10"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -180,10 +180,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vm_service
|
name: vm_service
|
||||||
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.2.0"
|
version: "15.0.2"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.11.0 <4.0.0"
|
dart: ">=3.11.0 <4.0.0"
|
||||||
flutter: ">=3.18.0-18.0.pre.54"
|
flutter: ">=3.18.0-18.0.pre.54"
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: async
|
name: async
|
||||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.13.1"
|
version: "2.13.0"
|
||||||
boolean_selector:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -77,10 +77,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: ffi
|
name: ffi
|
||||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.1.5"
|
||||||
file:
|
file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -124,10 +124,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: go_router
|
name: go_router
|
||||||
sha256: "92d8cee7c57dff0a6c409c05597b460002434eccf7424a712283225b3962d03f"
|
sha256: "5540e4a3f416dd4a93458257b908eb88353cbd0fb5b0a3d1bd7d849ba1e88735"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "17.2.3"
|
version: "17.2.1"
|
||||||
immich_ui:
|
immich_ui:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -211,10 +211,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.17.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -248,10 +248,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: source_span
|
name: source_span
|
||||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.2"
|
version: "1.10.1"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -312,10 +312,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.10"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -328,10 +328,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: uuid
|
name: uuid
|
||||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.5.3"
|
version: "4.5.2"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -344,10 +344,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vm_service
|
name: vm_service
|
||||||
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.2.0"
|
version: "15.0.2"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -110,6 +110,21 @@ class CloudIdResult {
|
|||||||
const CloudIdResult({required this.assetId, this.error, this.cloudId});
|
const CloudIdResult({required this.assetId, this.error, this.cloudId});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class BaseResource {
|
||||||
|
final String path;
|
||||||
|
final String sha1;
|
||||||
|
final int sizeBytes;
|
||||||
|
final String mimeType;
|
||||||
|
|
||||||
|
const BaseResource({required this.path, required this.sha1, required this.sizeBytes, required this.mimeType});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether an iOS asset currently carries a user edit, as opposed to a
|
||||||
|
// capture-time Photographic Style or a reverted edit. `unknown` means the
|
||||||
|
// adjustment data couldn't be read (e.g. the asset is offloaded to iCloud and
|
||||||
|
// network wasn't allowed), so callers must not treat it as "not edited".
|
||||||
|
enum EditState { notEdited, edited, unknown }
|
||||||
|
|
||||||
@HostApi()
|
@HostApi()
|
||||||
abstract class NativeSyncApi {
|
abstract class NativeSyncApi {
|
||||||
bool shouldFullSync();
|
bool shouldFullSync();
|
||||||
@@ -144,4 +159,12 @@ abstract class NativeSyncApi {
|
|||||||
|
|
||||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||||
List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds);
|
List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds);
|
||||||
|
|
||||||
|
@async
|
||||||
|
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||||
|
BaseResource? getBaseResource(String assetId, {bool allowNetworkAccess = false});
|
||||||
|
|
||||||
|
@async
|
||||||
|
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||||
|
EditState getEditState(String assetId, {bool allowNetworkAccess = false});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,4 @@ abstract class NetworkApi {
|
|||||||
int getClientPointer();
|
int getClientPointer();
|
||||||
|
|
||||||
void setRequestHeaders(Map<String, String> headers, List<String> serverUrls, String? token);
|
void setRequestHeaders(Map<String, String> headers, List<String> serverUrls, String? token);
|
||||||
|
|
||||||
String getAppGroupId();
|
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-44
@@ -133,10 +133,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: build
|
name: build
|
||||||
sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10
|
sha256: aadd943f4f8cc946882c954c187e6115a84c98c81ad1d9c6cbf0895a8c85da9c
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.6"
|
version: "4.0.5"
|
||||||
build_config:
|
build_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -157,10 +157,10 @@ packages:
|
|||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: build_runner
|
name: build_runner
|
||||||
sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6"
|
sha256: "521daf8d189deb79ba474e43a696b41c49fb3987818dbacf3308f1e03673a75e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.15.0"
|
version: "2.13.1"
|
||||||
built_collection:
|
built_collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -173,10 +173,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: built_value
|
name: built_value
|
||||||
sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56"
|
sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.12.6"
|
version: "8.12.5"
|
||||||
cast:
|
cast:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -358,18 +358,18 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: drift
|
name: drift
|
||||||
sha256: "8033500116b24398fba0cca0369cc31678cd627c01e41753a61186911cea743e"
|
sha256: "055c249d1f91be5a47fe447f88afc24c4ca6f4cd6c5ed66767b4797d48acc2e5"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.33.0"
|
version: "2.32.1"
|
||||||
drift_dev:
|
drift_dev:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: drift_dev
|
name: drift_dev
|
||||||
sha256: b3dd5b75e30522a91da8abda9f5bb17230cb038097f6d15fa75d42bb563428aa
|
sha256: "88a9de3af8571518148a6d8a513b57779fd1e60a026d3ab8a481a878fba01d91"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.33.0"
|
version: "2.32.1"
|
||||||
drift_flutter:
|
drift_flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -613,10 +613,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_svg
|
name: flutter_svg
|
||||||
sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f"
|
sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.2.4"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -772,10 +772,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: hooks
|
name: hooks
|
||||||
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
|
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.3"
|
version: "1.0.2"
|
||||||
hooks_riverpod:
|
hooks_riverpod:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -844,18 +844,18 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: image_picker
|
name: image_picker
|
||||||
sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac"
|
sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.2"
|
version: "1.2.1"
|
||||||
image_picker_android:
|
image_picker_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: image_picker_android
|
name: image_picker_android
|
||||||
sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f
|
sha256: "66810af8e99b2657ee98e5c6f02064f69bb63f7a70e343937f70946c5f8c6622"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.8.13+17"
|
version: "0.8.13+16"
|
||||||
image_picker_for_web:
|
image_picker_for_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -952,10 +952,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: json_annotation
|
name: json_annotation
|
||||||
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
|
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.12.0"
|
version: "4.11.0"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -984,10 +984,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: lean_builder
|
name: lean_builder
|
||||||
sha256: c16e95ddf7b2d49dd551357b7212fe2ce9f13ec7ad1b1e660c157184031e96c0
|
sha256: ee4117b03e93a4eb83e1a78c8e7a1dc22188d43bb142309982be48673a1b3a53
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.10"
|
version: "0.1.7"
|
||||||
lints:
|
lints:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1429,14 +1429,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.0"
|
version: "4.1.0"
|
||||||
record_use:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: record_use
|
|
||||||
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.6.0"
|
|
||||||
riverpod:
|
riverpod:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1615,10 +1607,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: source_gen
|
name: source_gen
|
||||||
sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02
|
sha256: "732792cfd197d2161a65bb029606a46e0a18ff30ef9e141a7a82172b05ea8ecd"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.2.3"
|
version: "4.2.2"
|
||||||
source_span:
|
source_span:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1655,10 +1647,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: sqlparser
|
name: sqlparser
|
||||||
sha256: ecdc06d4a7d79dcbc928d99afd2f7f5b0f98a637c46f89be83d911617f759978
|
sha256: ab2b467425f1d4f3acfa5fd11a08226f7d6c26ff102c06be1807e1dff34e050b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.44.4"
|
version: "0.44.3"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1815,10 +1807,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_web
|
name: url_launcher_web
|
||||||
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
|
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.3"
|
version: "2.4.2"
|
||||||
url_launcher_windows:
|
url_launcher_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1839,10 +1831,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_graphics
|
name: vector_graphics
|
||||||
sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d"
|
sha256: "81da85e9ca8885ade47f9685b953cb098970d11be4821ac765580a6607ea4373"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.2"
|
version: "1.1.21"
|
||||||
vector_graphics_codec:
|
vector_graphics_codec:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1855,10 +1847,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_graphics_compiler
|
name: vector_graphics_compiler
|
||||||
sha256: b9b3f391857781aa96acacef96066f2f49b4cd03cf9fce3ca4d8da2ef5ea129e
|
sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.3"
|
version: "1.2.0"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1871,10 +1863,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vm_service
|
name: vm_service
|
||||||
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.2.0"
|
version: "15.1.0"
|
||||||
wakelock_plus:
|
wakelock_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1887,10 +1879,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: wakelock_plus_platform_interface
|
name: wakelock_plus_platform_interface
|
||||||
sha256: b13f99e992e7ae6a152e16c5559d3c07ff445b13330192662494e614ca3e7d7b
|
sha256: "14b2e5b9e35c2631e656913c47adecdd71633ae92896a27a64c8f1fcfabc21cc"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.1"
|
version: "1.5.0"
|
||||||
watcher:
|
watcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
|
||||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||||
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
||||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
@@ -11,3 +12,5 @@ class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {}
|
|||||||
class MockNativeSyncApi extends Mock implements NativeSyncApi {}
|
class MockNativeSyncApi extends Mock implements NativeSyncApi {}
|
||||||
|
|
||||||
class MockAppSettingsService extends Mock implements AppSettingsService {}
|
class MockAppSettingsService extends Mock implements AppSettingsService {}
|
||||||
|
|
||||||
|
class MockEditRevertService extends Mock implements EditRevertService {}
|
||||||
|
|||||||
+8
@@ -30,6 +30,8 @@ import 'schema_v23.dart' as v23;
|
|||||||
import 'schema_v24.dart' as v24;
|
import 'schema_v24.dart' as v24;
|
||||||
import 'schema_v25.dart' as v25;
|
import 'schema_v25.dart' as v25;
|
||||||
import 'schema_v26.dart' as v26;
|
import 'schema_v26.dart' as v26;
|
||||||
|
import 'schema_v27.dart' as v27;
|
||||||
|
import 'schema_v28.dart' as v28;
|
||||||
|
|
||||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||||
@override
|
@override
|
||||||
@@ -87,6 +89,10 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
|||||||
return v25.DatabaseAtV25(db);
|
return v25.DatabaseAtV25(db);
|
||||||
case 26:
|
case 26:
|
||||||
return v26.DatabaseAtV26(db);
|
return v26.DatabaseAtV26(db);
|
||||||
|
case 27:
|
||||||
|
return v27.DatabaseAtV27(db);
|
||||||
|
case 28:
|
||||||
|
return v28.DatabaseAtV28(db);
|
||||||
default:
|
default:
|
||||||
throw MissingSchemaException(version, versions);
|
throw MissingSchemaException(version, versions);
|
||||||
}
|
}
|
||||||
@@ -119,5 +125,7 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
|||||||
24,
|
24,
|
||||||
25,
|
25,
|
||||||
26,
|
26,
|
||||||
|
27,
|
||||||
|
28,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+9425
File diff suppressed because it is too large
Load Diff
+9466
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
|||||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
||||||
@@ -36,6 +37,8 @@ class MockRemoteAssetRepository extends Mock implements RemoteAssetRepository {}
|
|||||||
|
|
||||||
class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {}
|
class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {}
|
||||||
|
|
||||||
|
class MockDriftStackRepository extends Mock implements DriftStackRepository {}
|
||||||
|
|
||||||
class MockStorageRepository extends Mock implements StorageRepository {}
|
class MockStorageRepository extends Mock implements StorageRepository {}
|
||||||
|
|
||||||
class MockDriftBackupRepository extends Mock implements DriftBackupRepository {}
|
class MockDriftBackupRepository extends Mock implements DriftBackupRepository {}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:background_downloader/background_downloader.dart';
|
||||||
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||||
import 'package:drift/native.dart';
|
import 'package:drift/native.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:immich_mobile/constants/constants.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||||
@@ -13,9 +15,11 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
|||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||||
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
import 'package:immich_mobile/services/background_upload.service.dart';
|
import 'package:immich_mobile/services/background_upload.service.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
import '../domain/service.mock.dart';
|
||||||
import '../fixtures/asset.stub.dart';
|
import '../fixtures/asset.stub.dart';
|
||||||
import '../infrastructure/repository.mock.dart';
|
import '../infrastructure/repository.mock.dart';
|
||||||
import '../mocks/asset_entity.mock.dart';
|
import '../mocks/asset_entity.mock.dart';
|
||||||
@@ -28,10 +32,14 @@ void main() {
|
|||||||
late MockDriftLocalAssetRepository mockLocalAssetRepository;
|
late MockDriftLocalAssetRepository mockLocalAssetRepository;
|
||||||
late MockDriftBackupRepository mockBackupRepository;
|
late MockDriftBackupRepository mockBackupRepository;
|
||||||
late MockAssetMediaRepository mockAssetMediaRepository;
|
late MockAssetMediaRepository mockAssetMediaRepository;
|
||||||
|
late MockNativeSyncApi mockNativeSyncApi;
|
||||||
|
late MockEditRevertService mockEditRevertService;
|
||||||
late Drift db;
|
late Drift db;
|
||||||
|
|
||||||
setUpAll(() async {
|
setUpAll(() async {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
registerFallbackValue(LocalAssetStub.image1);
|
||||||
|
registerFallbackValue(<UploadTask>[]);
|
||||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||||
const MethodChannel('plugins.flutter.io/path_provider'),
|
const MethodChannel('plugins.flutter.io/path_provider'),
|
||||||
(MethodCall methodCall) async => 'test',
|
(MethodCall methodCall) async => 'test',
|
||||||
@@ -50,6 +58,8 @@ void main() {
|
|||||||
mockLocalAssetRepository = MockDriftLocalAssetRepository();
|
mockLocalAssetRepository = MockDriftLocalAssetRepository();
|
||||||
mockBackupRepository = MockDriftBackupRepository();
|
mockBackupRepository = MockDriftBackupRepository();
|
||||||
mockAssetMediaRepository = MockAssetMediaRepository();
|
mockAssetMediaRepository = MockAssetMediaRepository();
|
||||||
|
mockNativeSyncApi = MockNativeSyncApi();
|
||||||
|
mockEditRevertService = MockEditRevertService();
|
||||||
|
|
||||||
sut = BackgroundUploadService(
|
sut = BackgroundUploadService(
|
||||||
mockUploadRepository,
|
mockUploadRepository,
|
||||||
@@ -57,8 +67,18 @@ void main() {
|
|||||||
mockLocalAssetRepository,
|
mockLocalAssetRepository,
|
||||||
mockBackupRepository,
|
mockBackupRepository,
|
||||||
mockAssetMediaRepository,
|
mockAssetMediaRepository,
|
||||||
|
mockNativeSyncApi,
|
||||||
|
mockEditRevertService,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Default: no edit base, so getUploadTask falls through to the normal path.
|
||||||
|
when(
|
||||||
|
() => mockNativeSyncApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||||
|
).thenAnswer((_) async => null);
|
||||||
|
|
||||||
|
// Default: not a revert, so getUploadTask proceeds with the normal flow.
|
||||||
|
when(() => mockEditRevertService.tryHandleRevert(any())).thenAnswer((_) async => false);
|
||||||
|
|
||||||
mockUploadRepository.onUploadStatus = (_) {};
|
mockUploadRepository.onUploadStatus = (_) {};
|
||||||
mockUploadRepository.onTaskProgress = (_) {};
|
mockUploadRepository.onTaskProgress = (_) {};
|
||||||
});
|
});
|
||||||
@@ -122,6 +142,171 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('getUploadTask edit pair', () {
|
||||||
|
test('absorption: stacks the edit under the prior upload via stackParentId', () async {
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||||
|
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||||
|
|
||||||
|
final asset = LocalAssetStub.image1.copyWith(priorRemoteId: 'prior-remote-1');
|
||||||
|
final mockEntity = MockAssetEntity();
|
||||||
|
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||||
|
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||||
|
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => File('/path/to/edit.jpg'));
|
||||||
|
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'edit.jpg');
|
||||||
|
|
||||||
|
final task = await sut.getUploadTask(asset);
|
||||||
|
|
||||||
|
expect(task, isNotNull);
|
||||||
|
expect(task!.group, kBackupEditPairGroup);
|
||||||
|
expect(task.fields['stackParentId'], 'prior-remote-1');
|
||||||
|
verifyNever(() => mockNativeSyncApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('builds a base upload task for an unsynced edit', () async {
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||||
|
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||||
|
|
||||||
|
final asset = LocalAssetStub.image1.copyWith(checksum: 'edited-sha1');
|
||||||
|
final mockEntity = MockAssetEntity();
|
||||||
|
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||||
|
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||||
|
when(
|
||||||
|
() => mockNativeSyncApi.getBaseResource(asset.id, allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||||
|
).thenAnswer(
|
||||||
|
(_) async => BaseResource(path: '/tmp/base.jpg', sha1: 'original-sha1', sizeBytes: 100, mimeType: 'image/jpeg'),
|
||||||
|
);
|
||||||
|
|
||||||
|
final task = await sut.getUploadTask(asset);
|
||||||
|
|
||||||
|
expect(task, isNotNull);
|
||||||
|
expect(task!.group, kBackupGroup);
|
||||||
|
expect(task.metaData, contains('"isEditPair":true'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls through to a normal upload when base bytes match the checksum', () async {
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||||
|
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||||
|
|
||||||
|
final asset = LocalAssetStub.image1.copyWith(checksum: 'same-sha1');
|
||||||
|
final mockEntity = MockAssetEntity();
|
||||||
|
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||||
|
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||||
|
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => File('/path/to/file.jpg'));
|
||||||
|
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'photo.jpg');
|
||||||
|
when(
|
||||||
|
() => mockNativeSyncApi.getBaseResource(asset.id, allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||||
|
).thenAnswer(
|
||||||
|
(_) async => BaseResource(path: '/tmp/base.jpg', sha1: 'same-sha1', sizeBytes: 100, mimeType: 'image/jpeg'),
|
||||||
|
);
|
||||||
|
|
||||||
|
final task = await sut.getUploadTask(asset);
|
||||||
|
|
||||||
|
expect(task, isNotNull);
|
||||||
|
expect(task!.group, kBackupGroup);
|
||||||
|
expect(task.fields.containsKey('stackParentId'), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('edit pair completion', () {
|
||||||
|
test('handleEditPair: enqueues the edit stacked onto the uploaded base', () async {
|
||||||
|
final asset = LocalAssetStub.image1;
|
||||||
|
final metadata = UploadTaskMetadata(
|
||||||
|
localAssetId: asset.id,
|
||||||
|
isLivePhotos: false,
|
||||||
|
livePhotoVideoId: '',
|
||||||
|
isEditPair: true,
|
||||||
|
);
|
||||||
|
final update = TaskStatusUpdate(
|
||||||
|
UploadTask(url: 'http://test-server.com', filename: 'base.jpg'),
|
||||||
|
TaskStatus.complete,
|
||||||
|
null,
|
||||||
|
'{"id":"base-remote-1"}',
|
||||||
|
);
|
||||||
|
final mockEntity = MockAssetEntity();
|
||||||
|
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||||
|
when(() => mockLocalAssetRepository.getById(asset.id)).thenAnswer((_) async => asset);
|
||||||
|
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||||
|
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => File('/path/to/edit.jpg'));
|
||||||
|
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'edit.jpg');
|
||||||
|
when(() => mockUploadRepository.enqueueBackgroundAll(any())).thenAnswer((_) async => [true]);
|
||||||
|
|
||||||
|
await sut.handleEditPair(update, metadata);
|
||||||
|
|
||||||
|
final enqueued =
|
||||||
|
verify(() => mockUploadRepository.enqueueBackgroundAll(captureAny())).captured.single as List<UploadTask>;
|
||||||
|
expect(enqueued.single.fields['stackParentId'], 'base-remote-1');
|
||||||
|
expect(enqueued.single.group, kBackupEditPairGroup);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleEditPair: does nothing for a non edit-pair upload', () async {
|
||||||
|
const metadata = UploadTaskMetadata(localAssetId: 'local-1', isLivePhotos: false, livePhotoVideoId: '');
|
||||||
|
final update = TaskStatusUpdate(
|
||||||
|
UploadTask(url: 'http://test-server.com', filename: 'photo.jpg'),
|
||||||
|
TaskStatus.complete,
|
||||||
|
null,
|
||||||
|
'{"id":"remote-1"}',
|
||||||
|
);
|
||||||
|
|
||||||
|
await sut.handleEditPair(update, metadata);
|
||||||
|
|
||||||
|
verifyNever(() => mockUploadRepository.enqueueBackgroundAll(any()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recordPriorRemoteIdOnSuccess: marks the local synced with the uploaded id', () async {
|
||||||
|
final asset = LocalAssetStub.image1;
|
||||||
|
final metadata = UploadTaskMetadata(localAssetId: asset.id, isLivePhotos: false, livePhotoVideoId: '');
|
||||||
|
final update = TaskStatusUpdate(
|
||||||
|
UploadTask(url: 'http://test-server.com', filename: 'photo.jpg'),
|
||||||
|
TaskStatus.complete,
|
||||||
|
null,
|
||||||
|
'{"id":"remote-1"}',
|
||||||
|
);
|
||||||
|
when(() => mockLocalAssetRepository.getById(asset.id)).thenAnswer((_) async => asset);
|
||||||
|
when(
|
||||||
|
() => mockLocalAssetRepository.markSynced(
|
||||||
|
any(),
|
||||||
|
priorRemoteId: any(named: 'priorRemoteId'),
|
||||||
|
syncedChecksum: any(named: 'syncedChecksum'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async {});
|
||||||
|
|
||||||
|
await sut.recordPriorRemoteIdOnSuccess(update, metadata);
|
||||||
|
|
||||||
|
verify(
|
||||||
|
() => mockLocalAssetRepository.markSynced(
|
||||||
|
asset.id,
|
||||||
|
priorRemoteId: 'remote-1',
|
||||||
|
syncedChecksum: asset.checksum ?? '',
|
||||||
|
),
|
||||||
|
).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recordPriorRemoteIdOnSuccess: skips edit-pair base uploads', () async {
|
||||||
|
const metadata = UploadTaskMetadata(
|
||||||
|
localAssetId: 'local-1',
|
||||||
|
isLivePhotos: false,
|
||||||
|
livePhotoVideoId: '',
|
||||||
|
isEditPair: true,
|
||||||
|
);
|
||||||
|
final update = TaskStatusUpdate(
|
||||||
|
UploadTask(url: 'http://test-server.com', filename: 'base.jpg'),
|
||||||
|
TaskStatus.complete,
|
||||||
|
null,
|
||||||
|
'{"id":"base-remote-1"}',
|
||||||
|
);
|
||||||
|
|
||||||
|
await sut.recordPriorRemoteIdOnSuccess(update, metadata);
|
||||||
|
|
||||||
|
verifyNever(
|
||||||
|
() => mockLocalAssetRepository.markSynced(
|
||||||
|
any(),
|
||||||
|
priorRemoteId: any(named: 'priorRemoteId'),
|
||||||
|
syncedChecksum: any(named: 'syncedChecksum'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('getLivePhotoUploadTask', () {
|
group('getLivePhotoUploadTask', () {
|
||||||
test('should call getOriginalFilename for live photo upload task', () async {
|
test('should call getOriginalFilename for live photo upload task', () async {
|
||||||
final asset = LocalAssetStub.image1;
|
final asset = LocalAssetStub.image1;
|
||||||
@@ -172,6 +357,8 @@ void main() {
|
|||||||
mockLocalAssetRepository,
|
mockLocalAssetRepository,
|
||||||
mockBackupRepository,
|
mockBackupRepository,
|
||||||
mockAssetMediaRepository,
|
mockAssetMediaRepository,
|
||||||
|
mockNativeSyncApi,
|
||||||
|
mockEditRevertService,
|
||||||
);
|
);
|
||||||
addTearDown(() => sutWithV24.dispose());
|
addTearDown(() => sutWithV24.dispose());
|
||||||
|
|
||||||
@@ -222,6 +409,8 @@ void main() {
|
|||||||
mockLocalAssetRepository,
|
mockLocalAssetRepository,
|
||||||
mockBackupRepository,
|
mockBackupRepository,
|
||||||
mockAssetMediaRepository,
|
mockAssetMediaRepository,
|
||||||
|
mockNativeSyncApi,
|
||||||
|
mockEditRevertService,
|
||||||
);
|
);
|
||||||
addTearDown(() => sutAndroid.dispose());
|
addTearDown(() => sutAndroid.dispose());
|
||||||
|
|
||||||
@@ -262,6 +451,8 @@ void main() {
|
|||||||
mockLocalAssetRepository,
|
mockLocalAssetRepository,
|
||||||
mockBackupRepository,
|
mockBackupRepository,
|
||||||
mockAssetMediaRepository,
|
mockAssetMediaRepository,
|
||||||
|
mockNativeSyncApi,
|
||||||
|
mockEditRevertService,
|
||||||
);
|
);
|
||||||
addTearDown(() => sutWithV24.dispose());
|
addTearDown(() => sutWithV24.dispose());
|
||||||
|
|
||||||
@@ -302,6 +493,8 @@ void main() {
|
|||||||
mockLocalAssetRepository,
|
mockLocalAssetRepository,
|
||||||
mockBackupRepository,
|
mockBackupRepository,
|
||||||
mockAssetMediaRepository,
|
mockAssetMediaRepository,
|
||||||
|
mockNativeSyncApi,
|
||||||
|
mockEditRevertService,
|
||||||
);
|
);
|
||||||
addTearDown(() => sutWithV24.dispose());
|
addTearDown(() => sutWithV24.dispose());
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import 'package:mocktail/mocktail.dart' as mocktail;
|
|||||||
|
|
||||||
import '../domain/service.mock.dart';
|
import '../domain/service.mock.dart';
|
||||||
import '../infrastructure/repository.mock.dart';
|
import '../infrastructure/repository.mock.dart';
|
||||||
|
import '../repository.mocks.dart';
|
||||||
|
|
||||||
class UnitMocks {
|
class UnitMocks {
|
||||||
final localAlbum = MockLocalAlbumRepository();
|
final localAlbum = MockLocalAlbumRepository();
|
||||||
final localAsset = MockDriftLocalAssetRepository();
|
final localAsset = MockDriftLocalAssetRepository();
|
||||||
final trashedAsset = MockTrashedLocalAssetRepository();
|
final trashedAsset = MockTrashedLocalAssetRepository();
|
||||||
|
final stack = MockDriftStackRepository();
|
||||||
|
final assetApi = MockAssetApiRepository();
|
||||||
|
|
||||||
final nativeApi = MockNativeSyncApi();
|
final nativeApi = MockNativeSyncApi();
|
||||||
|
|
||||||
@@ -31,6 +34,8 @@ class UnitMocks {
|
|||||||
mocktail.reset(localAlbum);
|
mocktail.reset(localAlbum);
|
||||||
mocktail.reset(localAsset);
|
mocktail.reset(localAsset);
|
||||||
mocktail.reset(trashedAsset);
|
mocktail.reset(trashedAsset);
|
||||||
|
mocktail.reset(stack);
|
||||||
|
mocktail.reset(assetApi);
|
||||||
mocktail.reset(nativeApi);
|
mocktail.reset(nativeApi);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
|
||||||
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
import '../mocks.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late EditRevertService sut;
|
||||||
|
final mocks = UnitMocks();
|
||||||
|
|
||||||
|
LocalAsset asset({String? priorRemoteId, String? checksum = 'reverted-sha1'}) => LocalAsset(
|
||||||
|
id: 'local-1',
|
||||||
|
name: 'photo.jpg',
|
||||||
|
type: AssetType.image,
|
||||||
|
createdAt: DateTime(2025),
|
||||||
|
updatedAt: DateTime(2025, 2),
|
||||||
|
playbackStyle: AssetPlaybackStyle.image,
|
||||||
|
isEdited: false,
|
||||||
|
priorRemoteId: priorRemoteId,
|
||||||
|
checksum: checksum,
|
||||||
|
);
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
sut = EditRevertService(
|
||||||
|
nativeSyncApi: mocks.nativeApi,
|
||||||
|
stackRepository: mocks.stack,
|
||||||
|
localAssetRepository: mocks.localAsset,
|
||||||
|
assetApiRepository: mocks.assetApi,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
mocks.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
group('tryHandleRevert', () {
|
||||||
|
test('returns false when the asset was never uploaded as an edit', () async {
|
||||||
|
expect(await sut.tryHandleRevert(asset(priorRemoteId: null)), isFalse);
|
||||||
|
verifyNever(() => mocks.nativeApi.getEditState(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false (lets the pair flow run) when there is still a live edit', () async {
|
||||||
|
when(
|
||||||
|
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||||
|
).thenAnswer((_) async => EditState.edited);
|
||||||
|
|
||||||
|
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), isFalse);
|
||||||
|
verifyNever(() => mocks.stack.findStackIdByRemoteId(any()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when the edit state cannot be read (offloaded to iCloud)', () async {
|
||||||
|
when(
|
||||||
|
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||||
|
).thenAnswer((_) async => EditState.unknown);
|
||||||
|
|
||||||
|
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), isFalse);
|
||||||
|
verifyNever(() => mocks.stack.findStackIdByRemoteId(any()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when the prior remote is not in a stack', () async {
|
||||||
|
when(
|
||||||
|
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||||
|
).thenAnswer((_) async => EditState.notEdited);
|
||||||
|
when(() => mocks.stack.findStackIdByRemoteId('remote-edit')).thenAnswer((_) async => null);
|
||||||
|
|
||||||
|
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), isFalse);
|
||||||
|
verifyNever(() => mocks.assetApi.setStackPrimary(any(), any()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when the stack has no base member to flip back to', () async {
|
||||||
|
when(
|
||||||
|
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||||
|
).thenAnswer((_) async => EditState.notEdited);
|
||||||
|
when(() => mocks.stack.findStackIdByRemoteId('remote-edit')).thenAnswer((_) async => 'stack-1');
|
||||||
|
when(() => mocks.stack.findStackBaseId('stack-1', excludeId: 'remote-edit')).thenAnswer((_) async => null);
|
||||||
|
|
||||||
|
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), isFalse);
|
||||||
|
verifyNever(() => mocks.assetApi.setStackPrimary(any(), any()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flips the primary back to the base via prior_remote_id and keeps the edit (no trash)', () async {
|
||||||
|
when(
|
||||||
|
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||||
|
).thenAnswer((_) async => EditState.notEdited);
|
||||||
|
when(() => mocks.stack.findStackIdByRemoteId('remote-edit')).thenAnswer((_) async => 'stack-1');
|
||||||
|
when(
|
||||||
|
() => mocks.stack.findStackBaseId('stack-1', excludeId: 'remote-edit'),
|
||||||
|
).thenAnswer((_) async => 'remote-base');
|
||||||
|
when(() => mocks.assetApi.setStackPrimary('stack-1', 'remote-base')).thenAnswer((_) async {});
|
||||||
|
when(() => mocks.stack.setPrimary('stack-1', 'remote-base')).thenAnswer((_) async {});
|
||||||
|
when(
|
||||||
|
() => mocks.localAsset.markSynced(
|
||||||
|
'local-1',
|
||||||
|
priorRemoteId: 'remote-base',
|
||||||
|
syncedChecksum: any(named: 'syncedChecksum'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async {});
|
||||||
|
|
||||||
|
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), isTrue);
|
||||||
|
|
||||||
|
verify(() => mocks.assetApi.setStackPrimary('stack-1', 'remote-base')).called(1);
|
||||||
|
verify(() => mocks.stack.setPrimary('stack-1', 'remote-base')).called(1);
|
||||||
|
verify(
|
||||||
|
() => mocks.localAsset.markSynced(
|
||||||
|
'local-1',
|
||||||
|
priorRemoteId: 'remote-base',
|
||||||
|
syncedChecksum: any(named: 'syncedChecksum'),
|
||||||
|
),
|
||||||
|
).called(1);
|
||||||
|
// Nothing is trashed or unstacked; every edit stays in the stack.
|
||||||
|
verifyNever(() => mocks.assetApi.delete(any(), any()));
|
||||||
|
verifyNever(() => mocks.assetApi.unStack(any()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/hash.service.dart';
|
import 'package:immich_mobile/domain/services/hash.service.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
|
||||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
@@ -18,6 +20,8 @@ void main() {
|
|||||||
localAssetRepository: mocks.localAsset,
|
localAssetRepository: mocks.localAsset,
|
||||||
nativeSyncApi: mocks.nativeApi,
|
nativeSyncApi: mocks.nativeApi,
|
||||||
trashedLocalAssetRepository: mocks.trashedAsset,
|
trashedLocalAssetRepository: mocks.trashedAsset,
|
||||||
|
stackRepository: mocks.stack,
|
||||||
|
assetApiRepository: mocks.assetApi,
|
||||||
);
|
);
|
||||||
|
|
||||||
when(() => mocks.localAsset.reconcileHashesFromCloudId()).thenAnswer((_) async => {});
|
when(() => mocks.localAsset.reconcileHashesFromCloudId()).thenAnswer((_) async => {});
|
||||||
@@ -110,6 +114,8 @@ void main() {
|
|||||||
nativeSyncApi: mocks.nativeApi,
|
nativeSyncApi: mocks.nativeApi,
|
||||||
batchSize: batchSize,
|
batchSize: batchSize,
|
||||||
trashedLocalAssetRepository: mocks.trashedAsset,
|
trashedLocalAssetRepository: mocks.trashedAsset,
|
||||||
|
stackRepository: mocks.stack,
|
||||||
|
assetApiRepository: mocks.assetApi,
|
||||||
);
|
);
|
||||||
|
|
||||||
final album = LocalAlbumFactory.create();
|
final album = LocalAlbumFactory.create();
|
||||||
@@ -183,5 +189,61 @@ void main() {
|
|||||||
verify(() => mocks.nativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
|
verify(() => mocks.nativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('iOS revert reconcile', () {
|
||||||
|
test('flips the stack primary for a non-styled revert that re-hashed to the base', () async {
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||||
|
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||||
|
registerFallbackValue(<String>[]);
|
||||||
|
|
||||||
|
final album = LocalAlbumFactory.create();
|
||||||
|
final asset = LocalAssetFactory.create();
|
||||||
|
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||||
|
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||||
|
when(
|
||||||
|
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
|
||||||
|
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: 'h')]);
|
||||||
|
|
||||||
|
const target = StackReconcileTarget(
|
||||||
|
stackId: 'stack-1',
|
||||||
|
newPrimaryId: 'base-1',
|
||||||
|
localAssetId: 'local-1',
|
||||||
|
localAssetChecksum: 'reverted-sha1',
|
||||||
|
);
|
||||||
|
when(() => mocks.stack.findRevertReconcileTargets(any())).thenAnswer((_) async => [target]);
|
||||||
|
when(() => mocks.assetApi.setStackPrimary('stack-1', 'base-1')).thenAnswer((_) async {});
|
||||||
|
when(() => mocks.stack.setPrimary('stack-1', 'base-1')).thenAnswer((_) async {});
|
||||||
|
when(
|
||||||
|
() => mocks.localAsset.markSynced('local-1', priorRemoteId: 'base-1', syncedChecksum: 'reverted-sha1'),
|
||||||
|
).thenAnswer((_) async {});
|
||||||
|
|
||||||
|
await sut.hashAssets();
|
||||||
|
|
||||||
|
verify(() => mocks.assetApi.setStackPrimary('stack-1', 'base-1')).called(1);
|
||||||
|
verify(() => mocks.stack.setPrimary('stack-1', 'base-1')).called(1);
|
||||||
|
verify(
|
||||||
|
() => mocks.localAsset.markSynced('local-1', priorRemoteId: 'base-1', syncedChecksum: 'reverted-sha1'),
|
||||||
|
).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not reconcile on a non-iOS platform', () async {
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||||
|
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||||
|
registerFallbackValue(<String>[]);
|
||||||
|
|
||||||
|
final album = LocalAlbumFactory.create();
|
||||||
|
final asset = LocalAssetFactory.create();
|
||||||
|
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||||
|
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||||
|
when(() => mocks.trashedAsset.getAssetsToHash(any())).thenAnswer((_) async => []);
|
||||||
|
when(
|
||||||
|
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
|
||||||
|
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: 'h')]);
|
||||||
|
|
||||||
|
await sut.hashAssets();
|
||||||
|
|
||||||
|
verifyNever(() => mocks.stack.findRevertReconcileTargets(any()));
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16490,6 +16490,12 @@
|
|||||||
"format": "binary",
|
"format": "binary",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"stackParentId": {
|
||||||
|
"description": "Stack this asset onto the parent asset, with the new asset as the stack primary",
|
||||||
|
"format": "uuid",
|
||||||
|
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"visibility": {
|
"visibility": {
|
||||||
"$ref": "#/components/schemas/AssetVisibility"
|
"$ref": "#/components/schemas/AssetVisibility"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
"format": "prettier --cache --check i18n/",
|
"format": "prettier --cache --check i18n/",
|
||||||
"format:fix": "prettier --cache --write --list-different i18n"
|
"format:fix": "prettier --cache --write --list-different i18n"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800",
|
"packageManager": "pnpm@10.33.1+sha512.05ba3c1d5d1c18f68df06470d74055e62d41fc110a0c660db1b2dfb2785327f04cf0f68345d4609bc52089e7fa0343c31593b2f9594e2c5d5da426230acc9820",
|
||||||
"engines": {
|
"engines": {
|
||||||
"pnpm": ">=10.0.0"
|
"pnpm": ">=10.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,5 +13,5 @@
|
|||||||
"oidc-provider": "^9.0.0",
|
"oidc-provider": "^9.0.0",
|
||||||
"tsx": "^4.20.6"
|
"tsx": "^4.20.6"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.33.4"
|
"packageManager": "pnpm@10.33.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,11 +24,11 @@
|
|||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"packageManager": "pnpm@10.33.4",
|
"packageManager": "pnpm@10.30.3",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@extism/js-pdk": "^1.1.1",
|
"@extism/js-pdk": "^1.1.1",
|
||||||
"@types/node": "^24.12.4",
|
"@types/node": "^24.12.4",
|
||||||
"esbuild": "^0.28.0",
|
"esbuild": "^0.27.3",
|
||||||
"tsc-alias": "^1.8.16",
|
"tsc-alias": "^1.8.16",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -630,6 +630,8 @@ export type AssetMediaCreateDto = {
|
|||||||
metadata?: AssetMetadataUpsertItemDto[];
|
metadata?: AssetMetadataUpsertItemDto[];
|
||||||
/** Sidecar file data */
|
/** Sidecar file data */
|
||||||
sidecarData?: Blob;
|
sidecarData?: Blob;
|
||||||
|
/** Stack this asset onto the parent asset, with the new asset as the stack primary */
|
||||||
|
stackParentId?: string;
|
||||||
visibility?: AssetVisibility;
|
visibility?: AssetVisibility;
|
||||||
};
|
};
|
||||||
export type AssetMediaResponseDto = {
|
export type AssetMediaResponseDto = {
|
||||||
|
|||||||
Generated
+3950
-4306
File diff suppressed because it is too large
Load Diff
+7
-7
@@ -49,14 +49,14 @@
|
|||||||
"@nestjs/websockets": "^11.0.4",
|
"@nestjs/websockets": "^11.0.4",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
"@opentelemetry/context-async-hooks": "^2.0.0",
|
"@opentelemetry/context-async-hooks": "^2.0.0",
|
||||||
"@opentelemetry/exporter-prometheus": "^0.218.0",
|
"@opentelemetry/exporter-prometheus": "^0.217.0",
|
||||||
"@opentelemetry/instrumentation-http": "^0.218.0",
|
"@opentelemetry/instrumentation-http": "^0.215.0",
|
||||||
"@opentelemetry/instrumentation-ioredis": "^0.66.0",
|
"@opentelemetry/instrumentation-ioredis": "^0.63.0",
|
||||||
"@opentelemetry/instrumentation-nestjs-core": "^0.64.0",
|
"@opentelemetry/instrumentation-nestjs-core": "^0.61.0",
|
||||||
"@opentelemetry/instrumentation-pg": "^0.70.0",
|
"@opentelemetry/instrumentation-pg": "^0.67.0",
|
||||||
"@opentelemetry/resources": "^2.0.1",
|
"@opentelemetry/resources": "^2.0.1",
|
||||||
"@opentelemetry/sdk-metrics": "^2.0.1",
|
"@opentelemetry/sdk-metrics": "^2.0.1",
|
||||||
"@opentelemetry/sdk-node": "^0.218.0",
|
"@opentelemetry/sdk-node": "^0.217.0",
|
||||||
"@opentelemetry/semantic-conventions": "^1.34.0",
|
"@opentelemetry/semantic-conventions": "^1.34.0",
|
||||||
"@react-email/components": "^1.0.0",
|
"@react-email/components": "^1.0.0",
|
||||||
"@react-email/render": "^2.0.0",
|
"@react-email/render": "^2.0.0",
|
||||||
@@ -116,7 +116,7 @@
|
|||||||
"ua-parser-js": "^2.0.0",
|
"ua-parser-js": "^2.0.0",
|
||||||
"uuid": "^14.0.0",
|
"uuid": "^14.0.0",
|
||||||
"validator": "^13.12.0",
|
"validator": "^13.12.0",
|
||||||
"zod": "4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.0",
|
"@eslint/js": "^10.0.0",
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ const AssetMediaCreateSchema = AssetMediaBaseSchema.extend({
|
|||||||
isFavorite: stringToBool.optional().describe('Mark as favorite'),
|
isFavorite: stringToBool.optional().describe('Mark as favorite'),
|
||||||
visibility: AssetVisibilitySchema.optional(),
|
visibility: AssetVisibilitySchema.optional(),
|
||||||
livePhotoVideoId: z.uuidv4().optional().describe('Live photo video ID'),
|
livePhotoVideoId: z.uuidv4().optional().describe('Live photo video ID'),
|
||||||
|
stackParentId: z
|
||||||
|
.uuidv4()
|
||||||
|
.optional()
|
||||||
|
.describe('Stack this asset onto the parent asset, with the new asset as the stack primary'),
|
||||||
metadata: JsonParsed.pipe(z.array(AssetMetadataUpsertItemSchema)).optional().describe('Asset metadata items'),
|
metadata: JsonParsed.pipe(z.array(AssetMetadataUpsertItemSchema)).optional().describe('Asset metadata items'),
|
||||||
[UploadFieldName.SIDECAR_DATA]: z
|
[UploadFieldName.SIDECAR_DATA]: z
|
||||||
.any()
|
.any()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { columns } from 'src/database';
|
|||||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||||
import { DB } from 'src/schema';
|
import { DB } from 'src/schema';
|
||||||
import { StackTable } from 'src/schema/tables/stack.table';
|
import { StackTable } from 'src/schema/tables/stack.table';
|
||||||
import { asUuid, withDefaultVisibility } from 'src/utils/database';
|
import { asUuid, isStackPrimaryConstraint, withDefaultVisibility } from 'src/utils/database';
|
||||||
|
|
||||||
export interface StackSearch {
|
export interface StackSearch {
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
@@ -124,6 +124,63 @@ export class StackRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async linkAsset(
|
||||||
|
ownerId: string,
|
||||||
|
newAssetId: string,
|
||||||
|
parentId: string,
|
||||||
|
): Promise<{ stackId: string; created: boolean } | null> {
|
||||||
|
try {
|
||||||
|
return await this.db.transaction().execute(async (tx) => {
|
||||||
|
// Lock the parent so two concurrent uploads can't each create a stack for it.
|
||||||
|
const parent = await tx
|
||||||
|
.selectFrom('asset')
|
||||||
|
.select(['id', 'ownerId', 'stackId', 'deletedAt'])
|
||||||
|
.where('id', '=', asUuid(parentId))
|
||||||
|
.forUpdate()
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!parent || parent.ownerId !== ownerId || parent.deletedAt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parent.stackId) {
|
||||||
|
await tx
|
||||||
|
.updateTable('asset')
|
||||||
|
.set({ stackId: parent.stackId, updatedAt: new Date() })
|
||||||
|
.where('id', '=', asUuid(newAssetId))
|
||||||
|
.execute();
|
||||||
|
await tx
|
||||||
|
.updateTable('stack')
|
||||||
|
.set({ primaryAssetId: newAssetId, updatedAt: new Date() })
|
||||||
|
.where('id', '=', parent.stackId)
|
||||||
|
.execute();
|
||||||
|
return { stackId: parent.stackId, created: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack = await tx
|
||||||
|
.insertInto('stack')
|
||||||
|
.values({ ownerId, primaryAssetId: newAssetId })
|
||||||
|
.returning('id')
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.updateTable('asset')
|
||||||
|
.set({ stackId: stack.id, updatedAt: new Date() })
|
||||||
|
.where('id', 'in', [asUuid(newAssetId), parent.id])
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return { stackId: stack.id, created: true };
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// newAssetId may already be another stack's primary (e.g. a retried upload) —
|
||||||
|
// treat the unique-constraint hit as "couldn't stack" rather than a 500.
|
||||||
|
if (isStackPrimaryConstraint(error)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
async delete(id: string): Promise<void> {
|
async delete(id: string): Promise<void> {
|
||||||
await this.db.deleteFrom('stack').where('id', '=', asUuid(id)).execute();
|
await this.db.deleteFrom('stack').where('id', '=', asUuid(id)).execute();
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export class NotificationTable {
|
|||||||
type!: Generated<NotificationType>;
|
type!: Generated<NotificationType>;
|
||||||
|
|
||||||
@Column({ type: 'jsonb', nullable: true })
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
data!: unknown | null;
|
data!: any | null;
|
||||||
|
|
||||||
@Column()
|
@Column()
|
||||||
title!: string;
|
title!: string;
|
||||||
|
|||||||
@@ -418,6 +418,79 @@ describe(AssetMediaService.name, () => {
|
|||||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should stack a new asset onto the parent and emit the populated stackId', async () => {
|
||||||
|
const file = {
|
||||||
|
uuid: 'random-uuid',
|
||||||
|
originalPath: 'fake_path/asset_1.jpeg',
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
checksum: Buffer.from('file hash', 'utf8'),
|
||||||
|
originalName: 'asset_1.jpeg',
|
||||||
|
size: 42,
|
||||||
|
};
|
||||||
|
const parent = AssetFactory.create();
|
||||||
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([parent.id]));
|
||||||
|
mocks.asset.getById.mockResolvedValueOnce(getForAsset(parent));
|
||||||
|
mocks.asset.create.mockResolvedValue(assetEntity);
|
||||||
|
mocks.stack.linkAsset.mockResolvedValue({ stackId: 'stack-1', created: true });
|
||||||
|
|
||||||
|
await expect(sut.uploadAsset(authStub.user1, { ...createDto, stackParentId: parent.id }, file)).resolves.toEqual({
|
||||||
|
id: 'id_1',
|
||||||
|
status: AssetMediaStatus.CREATED,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.stack.linkAsset).toHaveBeenCalledWith(authStub.user1.user.id, assetEntity.id, parent.id);
|
||||||
|
expect(mocks.event.emit).toHaveBeenCalledWith('AssetCreate', {
|
||||||
|
asset: expect.objectContaining({ stackId: 'stack-1' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject stacking onto a trashed asset', async () => {
|
||||||
|
const file = {
|
||||||
|
uuid: 'random-uuid',
|
||||||
|
originalPath: 'fake_path/asset_1.jpeg',
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
checksum: Buffer.from('file hash', 'utf8'),
|
||||||
|
originalName: 'asset_1.jpeg',
|
||||||
|
size: 42,
|
||||||
|
};
|
||||||
|
const parent = AssetFactory.create();
|
||||||
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([parent.id]));
|
||||||
|
mocks.asset.getById.mockResolvedValueOnce({ ...getForAsset(parent), deletedAt: new Date() });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sut.uploadAsset(authStub.user1, { ...createDto, stackParentId: parent.id }, file),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|
||||||
|
expect(mocks.asset.create).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.stack.linkAsset).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should adopt a duplicate into the stack when stacking', async () => {
|
||||||
|
const file = {
|
||||||
|
uuid: 'random-uuid',
|
||||||
|
originalPath: 'fake_path/asset_1.jpeg',
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
checksum: Buffer.from('file hash', 'utf8'),
|
||||||
|
originalName: 'asset_1.jpeg',
|
||||||
|
size: 0,
|
||||||
|
};
|
||||||
|
const parent = AssetFactory.create();
|
||||||
|
const error = new Error('unique key violation');
|
||||||
|
(error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT;
|
||||||
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([parent.id]));
|
||||||
|
mocks.asset.getById.mockResolvedValueOnce(getForAsset(parent));
|
||||||
|
mocks.asset.create.mockRejectedValue(error);
|
||||||
|
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('dup-id');
|
||||||
|
mocks.stack.linkAsset.mockResolvedValue({ stackId: 'stack-1', created: false });
|
||||||
|
|
||||||
|
await expect(sut.uploadAsset(authStub.user1, { ...createDto, stackParentId: parent.id }, file)).resolves.toEqual({
|
||||||
|
id: 'dup-id',
|
||||||
|
status: AssetMediaStatus.DUPLICATE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.stack.linkAsset).toHaveBeenCalledWith(authStub.user1.user.id, 'dup-id', parent.id);
|
||||||
|
});
|
||||||
|
|
||||||
it('should hide the linked motion asset', async () => {
|
it('should hide the linked motion asset', async () => {
|
||||||
const motionAsset = AssetFactory.from({ type: AssetType.Video }).owner(authStub.user1.user).build();
|
const motionAsset = AssetFactory.from({ type: AssetType.Video }).owner(authStub.user1.user).build();
|
||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
|
|||||||
@@ -140,26 +140,63 @@ export class AssetMediaService extends BaseService {
|
|||||||
|
|
||||||
this.requireQuota(auth, file.size);
|
this.requireQuota(auth, file.size);
|
||||||
|
|
||||||
|
if (dto.stackParentId) {
|
||||||
|
if (auth.sharedLink) {
|
||||||
|
throw new BadRequestException('Cannot stack an asset uploaded via shared link');
|
||||||
|
}
|
||||||
|
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [dto.stackParentId] });
|
||||||
|
const parent = await this.assetRepository.getById(dto.stackParentId);
|
||||||
|
if (!parent || parent.deletedAt) {
|
||||||
|
throw new BadRequestException('Cannot stack onto a trashed or missing asset');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (dto.livePhotoVideoId) {
|
if (dto.livePhotoVideoId) {
|
||||||
await onBeforeLink(
|
await onBeforeLink(
|
||||||
{ asset: this.assetRepository, event: this.eventRepository },
|
{ asset: this.assetRepository, event: this.eventRepository },
|
||||||
{ userId: auth.user.id, livePhotoVideoId: dto.livePhotoVideoId },
|
{ userId: auth.user.id, livePhotoVideoId: dto.livePhotoVideoId },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const asset = await this.create(auth.user.id, dto, file, sidecarFile);
|
// When stacking, defer the AssetCreate event and emit it below with the
|
||||||
|
// populated stackId, so clients don't briefly see the asset as standalone.
|
||||||
|
const asset = await this.create(auth.user.id, dto, file, sidecarFile, { skipEventEmit: !!dto.stackParentId });
|
||||||
|
|
||||||
if (auth.sharedLink) {
|
if (auth.sharedLink) {
|
||||||
await this.addToSharedLink(auth.sharedLink, asset.id);
|
await this.addToSharedLink(auth.sharedLink, asset.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dto.stackParentId) {
|
||||||
|
const linkResult = await this.linkToStackParent(auth.user.id, asset.id, dto.stackParentId);
|
||||||
|
await this.eventRepository.emit('AssetCreate', {
|
||||||
|
asset: linkResult ? { ...asset, stackId: linkResult.stackId } : asset,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await this.userRepository.updateUsage(auth.user.id, file.size);
|
await this.userRepository.updateUsage(auth.user.id, file.size);
|
||||||
|
|
||||||
return { id: asset.id, status: AssetMediaStatus.CREATED };
|
return { id: asset.id, status: AssetMediaStatus.CREATED };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return this.handleUploadError(error, auth, file, sidecarFile);
|
return this.handleUploadError(error, auth, file, sidecarFile, dto.stackParentId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async linkToStackParent(
|
||||||
|
ownerId: string,
|
||||||
|
newAssetId: string,
|
||||||
|
parentId: string,
|
||||||
|
): Promise<{ stackId: string; created: boolean } | null> {
|
||||||
|
const result = await this.stackRepository.linkAsset(ownerId, newAssetId, parentId);
|
||||||
|
if (!result) {
|
||||||
|
this.logger.warn(`Could not link asset ${newAssetId} to stack parent ${parentId}: parent missing or not owned`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await this.eventRepository.emit(result.created ? 'StackCreate' : 'StackUpdate', {
|
||||||
|
stackId: result.stackId,
|
||||||
|
userId: ownerId,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async downloadOriginal(auth: AuthDto, id: string, dto: AssetDownloadOriginalDto): Promise<ImmichFileResponse> {
|
async downloadOriginal(auth: AuthDto, id: string, dto: AssetDownloadOriginalDto): Promise<ImmichFileResponse> {
|
||||||
await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: [id] });
|
await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: [id] });
|
||||||
|
|
||||||
@@ -290,6 +327,7 @@ export class AssetMediaService extends BaseService {
|
|||||||
auth: AuthDto,
|
auth: AuthDto,
|
||||||
file: UploadFile,
|
file: UploadFile,
|
||||||
sidecarFile?: UploadFile,
|
sidecarFile?: UploadFile,
|
||||||
|
stackParentId?: string,
|
||||||
): Promise<AssetMediaResponseDto> {
|
): Promise<AssetMediaResponseDto> {
|
||||||
// clean up files
|
// clean up files
|
||||||
await this.jobRepository.queue({
|
await this.jobRepository.queue({
|
||||||
@@ -309,6 +347,12 @@ export class AssetMediaService extends BaseService {
|
|||||||
await this.addToSharedLink(auth.sharedLink, duplicateId);
|
await this.addToSharedLink(auth.sharedLink, duplicateId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (stackParentId) {
|
||||||
|
// Adopt the existing duplicate into the stack so a re-uploaded edit still
|
||||||
|
// stacks instead of silently staying separate.
|
||||||
|
await this.linkToStackParent(auth.user.id, duplicateId, stackParentId);
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.debug(`Duplicate asset upload rejected: existing asset ${duplicateId}`);
|
this.logger.debug(`Duplicate asset upload rejected: existing asset ${duplicateId}`);
|
||||||
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
|
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
|
||||||
}
|
}
|
||||||
@@ -317,7 +361,13 @@ export class AssetMediaService extends BaseService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async create(ownerId: string, dto: AssetMediaCreateDto, file: UploadFile, sidecarFile?: UploadFile) {
|
private async create(
|
||||||
|
ownerId: string,
|
||||||
|
dto: AssetMediaCreateDto,
|
||||||
|
file: UploadFile,
|
||||||
|
sidecarFile?: UploadFile,
|
||||||
|
options?: { skipEventEmit?: boolean },
|
||||||
|
) {
|
||||||
const asset = await this.assetRepository.create({
|
const asset = await this.assetRepository.create({
|
||||||
ownerId,
|
ownerId,
|
||||||
libraryId: null,
|
libraryId: null,
|
||||||
@@ -356,7 +406,9 @@ export class AssetMediaService extends BaseService {
|
|||||||
lockedPropertiesBehavior: 'override',
|
lockedPropertiesBehavior: 'override',
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.eventRepository.emit('AssetCreate', { asset });
|
if (!options?.skipEventEmit) {
|
||||||
|
await this.eventRepository.emit('AssetCreate', { asset });
|
||||||
|
}
|
||||||
|
|
||||||
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: asset.id, source: 'upload' } });
|
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: asset.id, source: 'upload' } });
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ export const isAssetChecksumConstraint = (error: unknown) => {
|
|||||||
return (error as PostgresError)?.constraint_name === 'UQ_assets_owner_checksum';
|
return (error as PostgresError)?.constraint_name === 'UQ_assets_owner_checksum';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const STACK_PRIMARY_CONSTRAINT = 'stack_primaryAssetId_uq';
|
||||||
|
|
||||||
|
export const isStackPrimaryConstraint = (error: unknown) => {
|
||||||
|
return (error as PostgresError)?.constraint_name === STACK_PRIMARY_CONSTRAINT;
|
||||||
|
};
|
||||||
|
|
||||||
export function withDefaultVisibility<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
|
export function withDefaultVisibility<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
|
||||||
return qb.where('asset.visibility', 'in', [sql.lit(AssetVisibility.Archive), sql.lit(AssetVisibility.Timeline)]);
|
return qb.where('asset.visibility', 'in', [sql.lit(AssetVisibility.Archive), sql.lit(AssetVisibility.Timeline)]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { INestApplication } from '@nestjs/common';
|
import { INestApplication } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiBodyOptions,
|
|
||||||
DocumentBuilder,
|
DocumentBuilder,
|
||||||
OpenAPIObject,
|
OpenAPIObject,
|
||||||
SwaggerCustomOptions,
|
SwaggerCustomOptions,
|
||||||
SwaggerDocumentOptions,
|
SwaggerDocumentOptions,
|
||||||
SwaggerModule,
|
SwaggerModule,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
OperationObject,
|
||||||
|
ReferenceObject,
|
||||||
|
SchemaObject,
|
||||||
|
} from '@nestjs/swagger/dist/interfaces/open-api-spec.interface';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { cleanupOpenApiDoc } from 'nestjs-zod';
|
import { cleanupOpenApiDoc } from 'nestjs-zod';
|
||||||
import { writeFileSync } from 'node:fs';
|
import { writeFileSync } from 'node:fs';
|
||||||
@@ -19,11 +23,6 @@ import { extraSyncModels } from 'src/dtos/sync.dto';
|
|||||||
import { ApiCustomExtension, ImmichCookie, ImmichHeader, MetadataKey } from 'src/enum';
|
import { ApiCustomExtension, ImmichCookie, ImmichHeader, MetadataKey } from 'src/enum';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
|
|
||||||
type OperationObject = NonNullable<OpenAPIObject['paths'][string]['get']>;
|
|
||||||
type ReferenceOrSchemaObject = Extract<ApiBodyOptions, { schema: unknown }>['schema'];
|
|
||||||
type ReferenceObject = Extract<ReferenceOrSchemaObject, { $ref: unknown }>;
|
|
||||||
type SchemaObject = Exclude<ReferenceOrSchemaObject, ReferenceObject>;
|
|
||||||
|
|
||||||
export class ImmichStartupError extends Error {}
|
export class ImmichStartupError extends Error {}
|
||||||
export const isStartUpError = (error: unknown): error is ImmichStartupError => error instanceof ImmichStartupError;
|
export const isStartUpError = (error: unknown): error is ImmichStartupError => error instanceof ImmichStartupError;
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -76,7 +76,7 @@
|
|||||||
"@sveltejs/adapter-static": "^3.0.8",
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
"@sveltejs/enhanced-img": "^0.10.4",
|
"@sveltejs/enhanced-img": "^0.10.4",
|
||||||
"@sveltejs/kit": "^2.56.1",
|
"@sveltejs/kit": "^2.56.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "7.1.2",
|
"@sveltejs/vite-plugin-svelte": "7.0.0",
|
||||||
"@tailwindcss/vite": "^4.2.4",
|
"@tailwindcss/vite": "^4.2.4",
|
||||||
"@testing-library/jest-dom": "^6.4.2",
|
"@testing-library/jest-dom": "^6.4.2",
|
||||||
"@testing-library/svelte": "^5.2.8",
|
"@testing-library/svelte": "^5.2.8",
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@utility immich-form-input {
|
@utility immich-form-input {
|
||||||
@apply bg-gray-100 ring-1 ring-gray-200 transition outline-none focus-within:ring-primary focus-within:ring-1 disabled:cursor-not-allowed dark:bg-gray-800 dark:ring-neutral-900 dark:focus-within:ring-primary flex w-full items-center rounded-lg disabled:bg-gray-300 disabled:text-dark dark:disabled:bg-gray-900 dark:disabled:text-gray-200 flex-1 py-2.5 text-base pl-4 pr-4;
|
@apply bg-gray-100 ring-1 ring-gray-200 transition outline-none focus-within:ring-1 disabled:cursor-not-allowed dark:bg-gray-800 dark:ring-neutral-900 flex w-full items-center rounded-lg disabled:bg-gray-300 disabled:text-dark dark:disabled:bg-gray-900 dark:disabled:text-gray-200 flex-1 py-2.5 text-base pl-4 pr-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility immich-form-label {
|
@utility immich-form-label {
|
||||||
|
|||||||
+1
-145
@@ -1,35 +1,17 @@
|
|||||||
import { defaultProvider, screencastManager, themeManager, ThemePreference, type ActionItem } from '@immich/ui';
|
import { defaultProvider, screencastManager, themeManager, ThemePreference, type ActionItem } from '@immich/ui';
|
||||||
import {
|
import {
|
||||||
mdiAccountMultipleOutline,
|
mdiAccountMultipleOutline,
|
||||||
mdiAccountOutline,
|
|
||||||
mdiArchiveArrowDownOutline,
|
|
||||||
mdiBookshelf,
|
mdiBookshelf,
|
||||||
mdiCog,
|
mdiCog,
|
||||||
mdiContentDuplicate,
|
|
||||||
mdiCrosshairsGps,
|
|
||||||
mdiFolderOutline,
|
|
||||||
mdiHeartOutline,
|
|
||||||
mdiImageAlbum,
|
|
||||||
mdiImageMultipleOutline,
|
|
||||||
mdiImageSizeSelectLarge,
|
|
||||||
mdiKeyboard,
|
mdiKeyboard,
|
||||||
mdiLink,
|
|
||||||
mdiLockOutline,
|
|
||||||
mdiMagnify,
|
|
||||||
mdiMapOutline,
|
|
||||||
mdiServer,
|
mdiServer,
|
||||||
mdiStateMachine,
|
|
||||||
mdiSync,
|
mdiSync,
|
||||||
mdiTagMultipleOutline,
|
|
||||||
mdiThemeLightDark,
|
mdiThemeLightDark,
|
||||||
mdiToolboxOutline,
|
|
||||||
mdiTrashCanOutline,
|
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import type { MessageFormatter } from 'svelte-i18n';
|
import type { MessageFormatter } from 'svelte-i18n';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
|
||||||
import { Route } from '$lib/route';
|
import { Route } from '$lib/route';
|
||||||
import { copyToClipboard } from '$lib/utils';
|
import { copyToClipboard } from '$lib/utils';
|
||||||
|
|
||||||
@@ -67,133 +49,7 @@ export const getPagesProvider = ($t: MessageFormatter) => {
|
|||||||
},
|
},
|
||||||
].map((route) => ({ ...route, $if: () => authManager.authenticated && authManager.user.isAdmin }));
|
].map((route) => ({ ...route, $if: () => authManager.authenticated && authManager.user.isAdmin }));
|
||||||
|
|
||||||
const userPages: ActionItem[] = [
|
return defaultProvider({ name: $t('page'), actions: adminPages });
|
||||||
{
|
|
||||||
title: $t('photos'),
|
|
||||||
icon: mdiImageMultipleOutline,
|
|
||||||
onAction: () => goto(Route.photos()),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('explore'),
|
|
||||||
icon: mdiMagnify,
|
|
||||||
onAction: () => goto(Route.explore()),
|
|
||||||
$if: () => authManager.authenticated && featureFlagsManager.value.search,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
title: $t('map'),
|
|
||||||
icon: mdiMapOutline,
|
|
||||||
onAction: () => goto(Route.map()),
|
|
||||||
$if: () => authManager.authenticated && featureFlagsManager.value.map,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('people'),
|
|
||||||
description: $t('people_feature_description'),
|
|
||||||
icon: mdiAccountOutline,
|
|
||||||
onAction: () => goto(Route.people()),
|
|
||||||
$if: () => authManager.authenticated && authManager.preferences.people.enabled,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('shared_links'),
|
|
||||||
icon: mdiLink,
|
|
||||||
onAction: () => goto(Route.sharedLinks()),
|
|
||||||
$if: () => authManager.authenticated && authManager.preferences.sharedLinks.enabled,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('recently_added'),
|
|
||||||
icon: mdiMagnify,
|
|
||||||
onAction: () => goto(Route.recentlyAdded()),
|
|
||||||
$if: () => authManager.authenticated,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('sharing'),
|
|
||||||
icon: mdiAccountMultipleOutline,
|
|
||||||
onAction: () => goto(Route.sharing()),
|
|
||||||
$if: () => authManager.authenticated,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('favorites'),
|
|
||||||
icon: mdiHeartOutline,
|
|
||||||
onAction: () => goto(Route.favorites()),
|
|
||||||
$if: () => authManager.authenticated,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('albums'),
|
|
||||||
description: $t('albums_feature_description'),
|
|
||||||
icon: mdiImageAlbum,
|
|
||||||
onAction: () => goto(Route.albums()),
|
|
||||||
$if: () => authManager.authenticated,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('tags'),
|
|
||||||
description: $t('tag_feature_description'),
|
|
||||||
icon: mdiTagMultipleOutline,
|
|
||||||
onAction: () => goto(Route.tags()),
|
|
||||||
$if: () => authManager.authenticated && authManager.preferences.tags.enabled,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('folders'),
|
|
||||||
description: $t('folders_feature_description'),
|
|
||||||
icon: mdiFolderOutline,
|
|
||||||
onAction: () => goto(Route.folders()),
|
|
||||||
$if: () => authManager.authenticated && authManager.preferences.folders.enabled,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('utilities'),
|
|
||||||
icon: mdiToolboxOutline,
|
|
||||||
onAction: () => goto(Route.utilities()),
|
|
||||||
$if: () => authManager.authenticated,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('archive'),
|
|
||||||
icon: mdiArchiveArrowDownOutline,
|
|
||||||
onAction: () => goto(Route.archive()),
|
|
||||||
$if: () => authManager.authenticated,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('locked_folder'),
|
|
||||||
icon: mdiLockOutline,
|
|
||||||
onAction: () => goto(Route.locked()),
|
|
||||||
$if: () => authManager.authenticated,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('trash'),
|
|
||||||
icon: mdiTrashCanOutline,
|
|
||||||
onAction: () => goto(Route.trash()),
|
|
||||||
$if: () => authManager.authenticated && featureFlagsManager.value.trash,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('admin.user_settings'),
|
|
||||||
icon: mdiCog,
|
|
||||||
onAction: () => goto(Route.userSettings()),
|
|
||||||
$if: () => authManager.authenticated,
|
|
||||||
},
|
|
||||||
].map((route) => ({ $if: () => authManager.authenticated, ...route }));
|
|
||||||
|
|
||||||
const utilityPages: ActionItem[] = [
|
|
||||||
{
|
|
||||||
title: $t('review_duplicates'),
|
|
||||||
icon: mdiContentDuplicate,
|
|
||||||
onAction: () => goto(Route.duplicatesUtility()),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('review_large_files'),
|
|
||||||
icon: mdiImageSizeSelectLarge,
|
|
||||||
onAction: () => goto(Route.largeFileUtility()),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('manage_geolocation'),
|
|
||||||
icon: mdiCrosshairsGps,
|
|
||||||
onAction: () => goto(Route.geolocationUtility()),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('workflows'),
|
|
||||||
icon: mdiStateMachine,
|
|
||||||
onAction: () => goto(Route.workflows()),
|
|
||||||
},
|
|
||||||
].map((route) => ({ ...route, $if: () => authManager.authenticated }));
|
|
||||||
|
|
||||||
return defaultProvider({ name: $t('page'), actions: [...userPages, ...utilityPages, ...adminPages] });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMyImmichLink = () => {
|
const getMyImmichLink = () => {
|
||||||
|
|||||||
@@ -128,7 +128,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if innerHeight}
|
{#if innerHeight}
|
||||||
<div
|
<div
|
||||||
class="relative w-full immich-scrollbar overflow-y-auto px-2"
|
class="relative w-full overflow-y-auto px-2 immich-scrollbar"
|
||||||
style="height: {divHeight}px;padding-bottom: {chatHeight}px"
|
style="height: {divHeight}px;padding-bottom: {chatHeight}px"
|
||||||
>
|
>
|
||||||
{#each activityManager.activities as reaction, index (reaction.id)}
|
{#each activityManager.activities as reaction, index (reaction.id)}
|
||||||
|
|||||||
@@ -153,7 +153,7 @@
|
|||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="mt-4 flex immich-scrollbar flex-wrap gap-2 overflow-y-auto">
|
<div class="mt-4 flex flex-wrap gap-2 overflow-y-auto immich-scrollbar">
|
||||||
{#each showPeople as person (person.id)}
|
{#each showPeople as person (person.id)}
|
||||||
{#if !editedFace.person || person.id !== editedFace.person.id}
|
{#if !editedFace.person || person.id !== editedFace.person.id}
|
||||||
<div class="w-fit">
|
<div class="w-fit">
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
<div
|
<div
|
||||||
bind:this={menuScrollView}
|
bind:this={menuScrollView}
|
||||||
class={[
|
class={[
|
||||||
'fixed z-1 w-max max-w-75 min-w-50 immich-scrollbar rounded-lg bg-slate-100 shadow-lg duration-250 ease-in-out',
|
'fixed z-1 w-max max-w-75 min-w-50 rounded-lg bg-slate-100 shadow-lg duration-250 ease-in-out immich-scrollbar',
|
||||||
position.needScrollBar ? 'overflow-auto' : 'overflow-hidden',
|
position.needScrollBar ? 'overflow-auto' : 'overflow-hidden',
|
||||||
]}
|
]}
|
||||||
style:left="{position.left}px"
|
style:left="{position.left}px"
|
||||||
|
|||||||
@@ -72,14 +72,14 @@
|
|||||||
? filterPeople(people, name)
|
? filterPeople(people, name)
|
||||||
: filterPeople(people, name).slice(0, numberOfPeople)}
|
: filterPeople(people, name).slice(0, numberOfPeople)}
|
||||||
|
|
||||||
<div id="people-selection" class="-mb-4 max-h-60 immich-scrollbar overflow-y-auto">
|
<div id="people-selection" class="-mb-4 max-h-60 overflow-y-auto immich-scrollbar">
|
||||||
<div class="flex w-full items-center justify-between gap-6">
|
<div class="flex w-full items-center justify-between gap-6">
|
||||||
<Text class="py-3" fontWeight="medium">{$t('people')}</Text>
|
<Text class="py-3" fontWeight="medium">{$t('people')}</Text>
|
||||||
<SearchBar bind:name placeholder={$t('filter_people')} showLoadingSpinner={false} />
|
<SearchBar bind:name placeholder={$t('filter_people')} showLoadingSpinner={false} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SingleGridRow
|
<SingleGridRow
|
||||||
class="space-between mt-2 grid immich-scrollbar grid-auto-fill-20 gap-1 overflow-y-auto"
|
class="space-between mt-2 grid grid-auto-fill-20 gap-1 overflow-y-auto immich-scrollbar"
|
||||||
bind:itemCount={numberOfPeople}
|
bind:itemCount={numberOfPeople}
|
||||||
>
|
>
|
||||||
{#each peopleList as person (person.id)}
|
{#each peopleList as person (person.id)}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="w-full immich-scrollbar overflow-y-auto rounded-2xl border border-gray-100 bg-gray-50 p-2 dark:border-gray-900 dark:bg-immich-dark-gray/50"
|
class="w-full overflow-y-auto rounded-2xl border border-gray-100 bg-gray-50 p-2 immich-scrollbar dark:border-gray-900 dark:bg-immich-dark-gray/50"
|
||||||
>
|
>
|
||||||
<ol class="flex items-center gap-2">
|
<ol class="flex items-center gap-2">
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
id="sidebar"
|
id="sidebar"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
class="relative z-1 w-0 immich-scrollbar overflow-x-hidden overflow-y-auto bg-light pt-8 transition-all duration-200 sidebar:w-64"
|
class="relative z-1 w-0 overflow-x-hidden overflow-y-auto bg-light pt-8 transition-all duration-200 immich-scrollbar sidebar:w-64"
|
||||||
class:shadow-2xl={isExpanded}
|
class:shadow-2xl={isExpanded}
|
||||||
class:dark:border-e-immich-dark-gray={isExpanded}
|
class:dark:border-e-immich-dark-gray={isExpanded}
|
||||||
class:border-r={isExpanded}
|
class:border-r={isExpanded}
|
||||||
|
|||||||
@@ -616,7 +616,7 @@
|
|||||||
<!-- Right margin MUST be equal to the width of scrubber -->
|
<!-- Right margin MUST be equal to the width of scrubber -->
|
||||||
<section
|
<section
|
||||||
id="asset-grid"
|
id="asset-grid"
|
||||||
class={['h-full scrollbar-hidden overflow-y-auto outline-none', { 'm-0': isEmpty }, { 'ms-0': !isEmpty }]}
|
class={['h-full overflow-y-auto outline-none scrollbar-hidden', { 'm-0': isEmpty }, { 'ms-0': !isEmpty }]}
|
||||||
style:margin-inline-end={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
|
style:margin-inline-end={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
bind:clientHeight={timelineManager.viewportHeight}
|
bind:clientHeight={timelineManager.viewportHeight}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@
|
|||||||
{#if showMenu}
|
{#if showMenu}
|
||||||
<div
|
<div
|
||||||
transition:fly={{ y: -30, duration: 250 }}
|
transition:fly={{ y: -30, duration: 250 }}
|
||||||
class="absolute z-1 flex max-h-[70vh] min-w-75 immich-scrollbar flex-col overflow-y-auto rounded-2xl bg-gray-100 py-2 text-sm font-medium text-black shadow-lg dark:bg-gray-700 dark:text-white {className} {getAlignClass(
|
class="absolute z-1 flex max-h-[70vh] min-w-75 flex-col overflow-y-auto rounded-2xl bg-gray-100 py-2 text-sm font-medium text-black shadow-lg immich-scrollbar dark:bg-gray-700 dark:text-white {className} {getAlignClass(
|
||||||
position,
|
position,
|
||||||
)}"
|
)}"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -172,7 +172,7 @@
|
|||||||
bind:value={search}
|
bind:value={search}
|
||||||
use:initInput
|
use:initInput
|
||||||
/>
|
/>
|
||||||
<div class="immich-scrollbar overflow-y-auto">
|
<div class="overflow-y-auto immich-scrollbar">
|
||||||
<!-- eslint-disable-next-line svelte/require-each-key -->
|
<!-- eslint-disable-next-line svelte/require-each-key -->
|
||||||
{#each albumModalRows as row}
|
{#each albumModalRows as row}
|
||||||
{#if row.type === AlbumModalRowType.NEW_ALBUM}
|
{#if row.type === AlbumModalRowType.NEW_ALBUM}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:then _}
|
{:then _}
|
||||||
{#if availableUsers.length > 0}
|
{#if availableUsers.length > 0}
|
||||||
<div class="flex max-h-75 immich-scrollbar flex-col gap-2 overflow-y-auto">
|
<div class="flex max-h-75 flex-col gap-2 overflow-y-auto immich-scrollbar">
|
||||||
{#each availableUsers as user (user.id)}
|
{#each availableUsers as user (user.id)}
|
||||||
<ListButton onclick={() => selectUser(user)} selected={selectedUsers.includes(user)}>
|
<ListButton onclick={() => selectUser(user)} selected={selectedUsers.includes(user)}>
|
||||||
<UserAvatar {user} size="md" />
|
<UserAvatar {user} size="md" />
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<SearchBar bind:name={searchName} placeholder={$t('search_people')} showLoadingSpinner={false} />
|
<SearchBar bind:name={searchName} placeholder={$t('search_people')} showLoadingSpinner={false} />
|
||||||
|
|
||||||
<div class="max-h-96 immich-scrollbar overflow-y-auto">
|
<div class="max-h-96 overflow-y-auto immich-scrollbar">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="flex justify-center p-8">
|
<div class="flex justify-center p-8">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { ServiceWorkerMessenger } from './sw-messenger';
|
import { ServiceWorkerMessenger } from './sw-messenger';
|
||||||
|
|
||||||
const hasServiceWorker = globalThis.isSecureContext && 'serviceWorker' in navigator;
|
const hasServiceWorker = globalThis.isSecureContext && 'serviceWorker' in navigator;
|
||||||
// eslint-disable-next-line compat/compat
|
|
||||||
const messenger = hasServiceWorker ? new ServiceWorkerMessenger(navigator.serviceWorker) : undefined;
|
const messenger = hasServiceWorker ? new ServiceWorkerMessenger(navigator.serviceWorker) : undefined;
|
||||||
|
|
||||||
export function cancelImageUrl(url: string | undefined | null) {
|
export function cancelImageUrl(url: string | undefined | null) {
|
||||||
|
|||||||
@@ -94,7 +94,7 @@
|
|||||||
|
|
||||||
<Breadcrumbs node={data.tree} icon={mdiFolderHome} title={$t('folders')} getLink={getLinkForPath} />
|
<Breadcrumbs node={data.tree} icon={mdiFolderHome} title={$t('folders')} getLink={getLinkForPath} />
|
||||||
|
|
||||||
<section class="mt-2 h-[calc(100%-(--spacing(25)))] immich-scrollbar overflow-auto">
|
<section class="mt-2 h-[calc(100%-(--spacing(25)))] overflow-auto immich-scrollbar">
|
||||||
<TreeItemThumbnails items={data.tree.children} icon={mdiFolder} onClick={handleNavigateToFolder} />
|
<TreeItemThumbnails items={data.tree.children} icon={mdiFolder} onClick={handleNavigateToFolder} />
|
||||||
|
|
||||||
<!-- Assets -->
|
<!-- Assets -->
|
||||||
|
|||||||
+1
-1
@@ -47,7 +47,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="mt-6 immich-scrollbar overflow-y-auto rounded-3xl bg-gray-200 p-10 dark:bg-immich-dark-gray"
|
class="mt-6 overflow-y-auto rounded-3xl bg-gray-200 p-10 immich-scrollbar dark:bg-immich-dark-gray"
|
||||||
style:max-height={screenHeight - 400 + 'px'}
|
style:max-height={screenHeight - 400 + 'px'}
|
||||||
>
|
>
|
||||||
<div class="grid-col-2 grid gap-8 md:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10">
|
<div class="grid-col-2 grid gap-8 md:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10">
|
||||||
|
|||||||
@@ -91,7 +91,7 @@
|
|||||||
|
|
||||||
<Breadcrumbs node={tag} icon={mdiTagMultiple} title={$t('tags')} {getLink} />
|
<Breadcrumbs node={tag} icon={mdiTagMultiple} title={$t('tags')} {getLink} />
|
||||||
|
|
||||||
<section class="mt-2 h-[calc(100%-(--spacing(20)))] immich-scrollbar overflow-auto">
|
<section class="mt-2 h-[calc(100%-(--spacing(20)))] overflow-auto immich-scrollbar">
|
||||||
{#if tag.hasAssets}
|
{#if tag.hasAssets}
|
||||||
<Timeline
|
<Timeline
|
||||||
enableRouting={true}
|
enableRouting={true}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if showOptions}
|
{#if showOptions}
|
||||||
<div class="mb-4 max-h-100 immich-scrollbar overflow-y-auto rounded-lg">
|
<div class="mb-4 max-h-100 overflow-y-auto rounded-lg immich-scrollbar">
|
||||||
<div class="flex h-6.5 place-items-center gap-1">
|
<div class="flex h-6.5 place-items-center gap-1">
|
||||||
<label class="immich-form-label" for="upload-concurrency">{$t('upload_concurrency')}</label>
|
<label class="immich-form-label" for="upload-concurrency">{$t('upload_concurrency')}</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex max-h-[400px] immich-scrollbar flex-col gap-2 overflow-y-auto rounded-lg">
|
<div class="flex max-h-[400px] flex-col gap-2 overflow-y-auto rounded-lg immich-scrollbar">
|
||||||
{#each $uploadAssetsStore as uploadAsset (uploadAsset.id)}
|
{#each $uploadAssetsStore as uploadAsset (uploadAsset.id)}
|
||||||
<UploadAssetPreview {uploadAsset} />
|
<UploadAssetPreview {uploadAsset} />
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
Reference in New Issue
Block a user