Compare commits

..

1 Commits

Author SHA1 Message Date
Santo Shakil 49a02ab2d9 feat(mobile): stack original + edited photo on ios
Detect an iOS edit, upload the unedited original, and stack the edited
version on top of it. Reverting in Photos flips the stack cover back to
the original and keeps the edits. Adds an optional stackParentId field to
the asset upload on the server.
2026-05-22 02:13:26 +06:00
96 changed files with 33026 additions and 4722 deletions
+1
View File
@@ -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
View File
@@ -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"
+64 -5
View File
@@ -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"
+1 -1
View File
@@ -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())
@@ -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))
}
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -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;
-14
View File
@@ -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 {
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
+118 -10
View File
@@ -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)
}
} }
} }
+167
View File
@@ -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>
+1 -1
View File
@@ -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
View File
@@ -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>
+6 -16
View File
@@ -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
@@ -106,7 +97,7 @@ end
if version_number if version_number
increment_version_number(version_number: version_number) increment_version_number(version_number: version_number)
end end
# Increment build number # Increment build number
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number( build_number: latest_testflight_build_number(
@@ -115,14 +106,14 @@ end
) + 1, ) + 1,
xcodeproj: "./Runner.xcodeproj" xcodeproj: "./Runner.xcodeproj"
) )
# Build the app # Build the app
build_app( build_app(
scheme: "Runner", scheme: "Runner",
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,
+2
View File
@@ -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;
}
}
+62 -6
View File
@@ -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,
); );
} }
@@ -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
View File
@@ -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
View File
@@ -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;
}
} }
-19
View File
@@ -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.
+57
View File
@@ -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,
+2 -2
View File
@@ -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, "");
+13 -3
View File
@@ -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));
} }
+10 -10
View File
@@ -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"
+16 -16
View File
@@ -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:
+23
View File
@@ -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});
} }
-2
View File
@@ -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
View File
@@ -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:
+3
View File
@@ -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
View File
@@ -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,
]; ];
} }
File diff suppressed because it is too large Load Diff
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());
+5
View File
@@ -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()));
});
});
}); });
} }
+6
View File
@@ -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
View File
@@ -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"
}, },
+1 -1
View File
@@ -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"
} }
+2 -2
View File
@@ -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"
}, },
+2
View File
@@ -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 = {
+3950 -4306
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -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",
+4
View File
@@ -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()
+58 -1
View File
@@ -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();
+56 -4
View File
@@ -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' } });
+6
View File
@@ -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)]);
} }
+5 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+1 -1
View File
@@ -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,
)}" )}"
> >
+1 -1
View File
@@ -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" />
+1 -1
View File
@@ -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
View File
@@ -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 -->
@@ -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}
+2 -2
View File
@@ -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}