mirror of
https://github.com/immich-app/immich.git
synced 2026-06-23 07:06:43 -07:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f84dbd1fda | |||
| 697cbe5374 | |||
| bedb621ef4 | |||
| 8e8057a327 | |||
| e7a7129902 | |||
| 263b812ac3 | |||
| c6d83ed586 | |||
| 65a7b49a91 |
@@ -103,7 +103,7 @@ jobs:
|
||||
working-directory: ./mobile
|
||||
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
|
||||
|
||||
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
@@ -25,12 +25,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for breaking API changes
|
||||
uses: oasdiff/oasdiff-action/breaking@e24529087d93f837b28b50bb66ba9016380a7fcc # v0.1.2
|
||||
uses: oasdiff/oasdiff-action/breaking@3530478ec30f84adedbfeb28f0d9527a290f50a9 # v0.0.57
|
||||
with:
|
||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||
revision: open-api/immich-openapi-specs.json
|
||||
fail-on: ERR
|
||||
review: false
|
||||
|
||||
check-mobile-patches:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -406,7 +406,7 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
@@ -483,7 +483,7 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ DB_DATA_LOCATION=./postgres
|
||||
# TZ=Etc/UTC
|
||||
|
||||
# The Immich version to use. You can pin this to a specific version like "v2.1.0"
|
||||
IMMICH_VERSION=v3
|
||||
IMMICH_VERSION=v2
|
||||
|
||||
# Connection secret for postgres. You should change it to a random password
|
||||
# Please use only the characters `A-Za-z0-9`, without special characters or spaces
|
||||
|
||||
@@ -19,7 +19,7 @@ If this does not work, try running `docker compose up -d --force-recreate`.
|
||||
|
||||
| Variable | Description | Default | Containers |
|
||||
| :----------------- | :------------------------------ | :-----: | :----------------------- |
|
||||
| `IMMICH_VERSION` | Image tags | `v3` | server, machine learning |
|
||||
| `IMMICH_VERSION` | Image tags | `v2` | server, machine learning |
|
||||
| `UPLOAD_LOCATION` | Host path for uploads | | server |
|
||||
| `DB_DATA_LOCATION` | Host path for Postgres database | | database |
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ docker image prune
|
||||
## Versioning Policy
|
||||
|
||||
Immich follows [semantic versioning][semver], which tags releases in the format `<major>.<minor>.<patch>`. We intend for breaking changes to be limited to major version releases.
|
||||
You can configure your Docker image to point to the current major version by using a metatag, such as `:v3`.
|
||||
You can configure your Docker image to point to the current major version by using a metatag, such as `:v2`.
|
||||
|
||||
Currently, we have no plans to backport patches to earlier versions. We encourage all users to run the most recent release of Immich.
|
||||
Switching back to an earlier version, even within the same minor release tag, is not supported.
|
||||
|
||||
+2
-1
@@ -1548,7 +1548,7 @@
|
||||
"map_location_picker_page_use_location": "Use this location",
|
||||
"map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?",
|
||||
"map_location_service_disabled_title": "Location Service disabled",
|
||||
"map_marker_for_image": "Map marker for image taken in {city}, {country}",
|
||||
"map_marker_for_images": "Map marker for images taken in {city}, {country}",
|
||||
"map_marker_with_image": "Map marker with image",
|
||||
"map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?",
|
||||
"map_no_location_permission_title": "Location Permission denied",
|
||||
@@ -2443,6 +2443,7 @@
|
||||
"upload": "Upload",
|
||||
"upload_concurrency": "Upload concurrency",
|
||||
"upload_day_count": "{date}: {count, plural, one {# upload} other {# uploads}}",
|
||||
"upload_deferred_edit_pair": "Waiting for the original photo, will retry automatically",
|
||||
"upload_details": "Upload Details",
|
||||
"upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?",
|
||||
"upload_dialog_title": "Upload Asset",
|
||||
|
||||
+233
-14
@@ -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. */
|
||||
data class PlatformAsset (
|
||||
val id: String,
|
||||
@@ -222,7 +234,9 @@ data class PlatformAsset (
|
||||
val adjustmentTime: Long? = null,
|
||||
val latitude: Double? = null,
|
||||
val longitude: Double? = null,
|
||||
val playbackStyle: PlatformAssetPlaybackStyle
|
||||
val playbackStyle: PlatformAssetPlaybackStyle,
|
||||
val burstId: String? = null,
|
||||
val isBurstRepresentative: Boolean
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
@@ -241,7 +255,9 @@ data class PlatformAsset (
|
||||
val latitude = pigeonVar_list[11] as Double?
|
||||
val longitude = pigeonVar_list[12] as Double?
|
||||
val playbackStyle = pigeonVar_list[13] as PlatformAssetPlaybackStyle
|
||||
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationMs, orientation, isFavorite, adjustmentTime, latitude, longitude, playbackStyle)
|
||||
val burstId = pigeonVar_list[14] as String?
|
||||
val isBurstRepresentative = pigeonVar_list[15] as Boolean
|
||||
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationMs, orientation, isFavorite, adjustmentTime, latitude, longitude, playbackStyle, burstId, isBurstRepresentative)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
@@ -260,6 +276,8 @@ data class PlatformAsset (
|
||||
latitude,
|
||||
longitude,
|
||||
playbackStyle,
|
||||
burstId,
|
||||
isBurstRepresentative,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
@@ -270,7 +288,7 @@ data class PlatformAsset (
|
||||
return true
|
||||
}
|
||||
val other = other as PlatformAsset
|
||||
return MessagesPigeonUtils.deepEquals(this.id, other.id) && MessagesPigeonUtils.deepEquals(this.name, other.name) && MessagesPigeonUtils.deepEquals(this.type, other.type) && MessagesPigeonUtils.deepEquals(this.createdAt, other.createdAt) && MessagesPigeonUtils.deepEquals(this.updatedAt, other.updatedAt) && MessagesPigeonUtils.deepEquals(this.width, other.width) && MessagesPigeonUtils.deepEquals(this.height, other.height) && MessagesPigeonUtils.deepEquals(this.durationMs, other.durationMs) && MessagesPigeonUtils.deepEquals(this.orientation, other.orientation) && MessagesPigeonUtils.deepEquals(this.isFavorite, other.isFavorite) && MessagesPigeonUtils.deepEquals(this.adjustmentTime, other.adjustmentTime) && MessagesPigeonUtils.deepEquals(this.latitude, other.latitude) && MessagesPigeonUtils.deepEquals(this.longitude, other.longitude) && MessagesPigeonUtils.deepEquals(this.playbackStyle, other.playbackStyle)
|
||||
return MessagesPigeonUtils.deepEquals(this.id, other.id) && MessagesPigeonUtils.deepEquals(this.name, other.name) && MessagesPigeonUtils.deepEquals(this.type, other.type) && MessagesPigeonUtils.deepEquals(this.createdAt, other.createdAt) && MessagesPigeonUtils.deepEquals(this.updatedAt, other.updatedAt) && MessagesPigeonUtils.deepEquals(this.width, other.width) && MessagesPigeonUtils.deepEquals(this.height, other.height) && MessagesPigeonUtils.deepEquals(this.durationMs, other.durationMs) && MessagesPigeonUtils.deepEquals(this.orientation, other.orientation) && MessagesPigeonUtils.deepEquals(this.isFavorite, other.isFavorite) && MessagesPigeonUtils.deepEquals(this.adjustmentTime, other.adjustmentTime) && MessagesPigeonUtils.deepEquals(this.latitude, other.latitude) && MessagesPigeonUtils.deepEquals(this.longitude, other.longitude) && MessagesPigeonUtils.deepEquals(this.playbackStyle, other.playbackStyle) && MessagesPigeonUtils.deepEquals(this.burstId, other.burstId) && MessagesPigeonUtils.deepEquals(this.isBurstRepresentative, other.isBurstRepresentative)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
@@ -289,6 +307,8 @@ data class PlatformAsset (
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.latitude)
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.longitude)
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.playbackStyle)
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.burstId)
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.isBurstRepresentative)
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -472,6 +492,82 @@ data class CloudIdResult (
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class BaseResource (
|
||||
val path: String,
|
||||
val sha1: String
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): BaseResource {
|
||||
val path = pigeonVar_list[0] as String
|
||||
val sha1 = pigeonVar_list[1] as String
|
||||
return BaseResource(path, sha1)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
path,
|
||||
sha1,
|
||||
)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = javaClass.hashCode()
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.path)
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.sha1)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class BaseLivePhoto (
|
||||
val still: BaseResource,
|
||||
val video: BaseResource? = null
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): BaseLivePhoto {
|
||||
val still = pigeonVar_list[0] as BaseResource
|
||||
val video = pigeonVar_list[1] as BaseResource?
|
||||
return BaseLivePhoto(still, video)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
still,
|
||||
video,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other.javaClass != javaClass) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
val other = other as BaseLivePhoto
|
||||
return MessagesPigeonUtils.deepEquals(this.still, other.still) && MessagesPigeonUtils.deepEquals(this.video, other.video)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = javaClass.hashCode()
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.still)
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.video)
|
||||
return result
|
||||
}
|
||||
}
|
||||
private open class MessagesPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return when (type) {
|
||||
@@ -481,30 +577,45 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
||||
}
|
||||
}
|
||||
130.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
PlatformAsset.fromList(it)
|
||||
return (readValue(buffer) as Long?)?.let {
|
||||
EditState.ofRaw(it.toInt())
|
||||
}
|
||||
}
|
||||
131.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
PlatformAlbum.fromList(it)
|
||||
PlatformAsset.fromList(it)
|
||||
}
|
||||
}
|
||||
132.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
SyncDelta.fromList(it)
|
||||
PlatformAlbum.fromList(it)
|
||||
}
|
||||
}
|
||||
133.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
HashResult.fromList(it)
|
||||
SyncDelta.fromList(it)
|
||||
}
|
||||
}
|
||||
134.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
HashResult.fromList(it)
|
||||
}
|
||||
}
|
||||
135.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
CloudIdResult.fromList(it)
|
||||
}
|
||||
}
|
||||
136.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
BaseResource.fromList(it)
|
||||
}
|
||||
}
|
||||
137.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
BaseLivePhoto.fromList(it)
|
||||
}
|
||||
}
|
||||
else -> super.readValueOfType(type, buffer)
|
||||
}
|
||||
}
|
||||
@@ -514,26 +625,38 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
||||
stream.write(129)
|
||||
writeValue(stream, value.raw.toLong())
|
||||
}
|
||||
is PlatformAsset -> {
|
||||
is EditState -> {
|
||||
stream.write(130)
|
||||
writeValue(stream, value.toList())
|
||||
writeValue(stream, value.raw.toLong())
|
||||
}
|
||||
is PlatformAlbum -> {
|
||||
is PlatformAsset -> {
|
||||
stream.write(131)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is SyncDelta -> {
|
||||
is PlatformAlbum -> {
|
||||
stream.write(132)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is HashResult -> {
|
||||
is SyncDelta -> {
|
||||
stream.write(133)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is CloudIdResult -> {
|
||||
is HashResult -> {
|
||||
stream.write(134)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is CloudIdResult -> {
|
||||
stream.write(135)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is BaseResource -> {
|
||||
stream.write(136)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is BaseLivePhoto -> {
|
||||
stream.write(137)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
}
|
||||
}
|
||||
@@ -556,6 +679,18 @@ interface NativeSyncApi {
|
||||
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
|
||||
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit)
|
||||
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
|
||||
fun getBaseResource(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseResource?>) -> Unit)
|
||||
/**
|
||||
* Streams the bytes immich treats as the asset's canonical content — the same
|
||||
* resource [hashAssets] hashes (`PHAsset.getResource()`, the `.isCurrent`
|
||||
* rendition). Used to upload iOS burst members: they're invisible to
|
||||
* photo_manager, so this is the only way to read their file, and streaming
|
||||
* the same resource the hash measured keeps the server checksum aligned with
|
||||
* the local one (else the asset shows cloud-only). iOS-only; android returns null.
|
||||
*/
|
||||
fun getCurrentResource(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseResource?>) -> Unit)
|
||||
fun getEditState(assetId: String, allowNetworkAccess: Boolean, callback: (Result<EditState>) -> Unit)
|
||||
fun getBaseLivePhoto(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseLivePhoto?>) -> Unit)
|
||||
|
||||
companion object {
|
||||
/** The codec used by NativeSyncApi. */
|
||||
@@ -818,6 +953,90 @@ interface NativeSyncApi {
|
||||
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.getCurrentResource$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.getCurrentResource(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)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseLivePhoto$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.getBaseLivePhoto(assetIdArg, allowNetworkAccessArg) { result: Result<BaseLivePhoto?> ->
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,6 +204,8 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
|
||||
0L,
|
||||
isFavorite,
|
||||
playbackStyle = playbackStyle,
|
||||
// Android has no burstIdentifier equivalent in MediaStore — bursts are iOS-only.
|
||||
isBurstRepresentative = false,
|
||||
)
|
||||
yield(AssetResult.ValidAsset(asset, bucketId))
|
||||
}
|
||||
@@ -509,4 +511,25 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
|
||||
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
// Android has no Photos-style edit original to stack; iOS-only.
|
||||
fun getBaseResource(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseResource?>) -> Unit) {
|
||||
completeWhenActive(callback, Result.success(null))
|
||||
}
|
||||
|
||||
// iOS-only; burst members are an iOS concept. Android resolves every asset via
|
||||
// MediaStore already, so there's no hidden-member byte fetch to provide.
|
||||
fun getCurrentResource(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseResource?>) -> Unit) {
|
||||
completeWhenActive(callback, Result.success(null))
|
||||
}
|
||||
|
||||
// iOS-only; Android assets never carry a Photos-style edit.
|
||||
fun getEditState(assetId: String, allowNetworkAccess: Boolean, callback: (Result<EditState>) -> Unit) {
|
||||
completeWhenActive(callback, Result.success(EditState.NOT_EDITED))
|
||||
}
|
||||
|
||||
// iOS-only; Android assets never carry a Photos-style live edit.
|
||||
fun getBaseLivePhoto(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseLivePhoto?>) -> Unit) {
|
||||
completeWhenActive(callback, Result.success(null))
|
||||
}
|
||||
}
|
||||
|
||||
+3693
File diff suppressed because it is too large
Load Diff
Generated
+215
-12
@@ -183,6 +183,12 @@ enum PlatformAssetPlaybackStyle: Int {
|
||||
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.
|
||||
struct PlatformAsset: Hashable {
|
||||
var id: String
|
||||
@@ -199,6 +205,8 @@ struct PlatformAsset: Hashable {
|
||||
var latitude: Double? = nil
|
||||
var longitude: Double? = nil
|
||||
var playbackStyle: PlatformAssetPlaybackStyle
|
||||
var burstId: String? = nil
|
||||
var isBurstRepresentative: Bool
|
||||
|
||||
|
||||
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||
@@ -217,6 +225,8 @@ struct PlatformAsset: Hashable {
|
||||
let latitude: Double? = nilOrValue(pigeonVar_list[11])
|
||||
let longitude: Double? = nilOrValue(pigeonVar_list[12])
|
||||
let playbackStyle = pigeonVar_list[13] as! PlatformAssetPlaybackStyle
|
||||
let burstId: String? = nilOrValue(pigeonVar_list[14])
|
||||
let isBurstRepresentative = pigeonVar_list[15] as! Bool
|
||||
|
||||
return PlatformAsset(
|
||||
id: id,
|
||||
@@ -232,7 +242,9 @@ struct PlatformAsset: Hashable {
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
playbackStyle: playbackStyle
|
||||
playbackStyle: playbackStyle,
|
||||
burstId: burstId,
|
||||
isBurstRepresentative: isBurstRepresentative
|
||||
)
|
||||
}
|
||||
func toList() -> [Any?] {
|
||||
@@ -251,13 +263,15 @@ struct PlatformAsset: Hashable {
|
||||
latitude,
|
||||
longitude,
|
||||
playbackStyle,
|
||||
burstId,
|
||||
isBurstRepresentative,
|
||||
]
|
||||
}
|
||||
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {
|
||||
if Swift.type(of: lhs) != Swift.type(of: rhs) {
|
||||
return false
|
||||
}
|
||||
return deepEqualsMessages(lhs.id, rhs.id) && deepEqualsMessages(lhs.name, rhs.name) && deepEqualsMessages(lhs.type, rhs.type) && deepEqualsMessages(lhs.createdAt, rhs.createdAt) && deepEqualsMessages(lhs.updatedAt, rhs.updatedAt) && deepEqualsMessages(lhs.width, rhs.width) && deepEqualsMessages(lhs.height, rhs.height) && deepEqualsMessages(lhs.durationMs, rhs.durationMs) && deepEqualsMessages(lhs.orientation, rhs.orientation) && deepEqualsMessages(lhs.isFavorite, rhs.isFavorite) && deepEqualsMessages(lhs.adjustmentTime, rhs.adjustmentTime) && deepEqualsMessages(lhs.latitude, rhs.latitude) && deepEqualsMessages(lhs.longitude, rhs.longitude) && deepEqualsMessages(lhs.playbackStyle, rhs.playbackStyle)
|
||||
return deepEqualsMessages(lhs.id, rhs.id) && deepEqualsMessages(lhs.name, rhs.name) && deepEqualsMessages(lhs.type, rhs.type) && deepEqualsMessages(lhs.createdAt, rhs.createdAt) && deepEqualsMessages(lhs.updatedAt, rhs.updatedAt) && deepEqualsMessages(lhs.width, rhs.width) && deepEqualsMessages(lhs.height, rhs.height) && deepEqualsMessages(lhs.durationMs, rhs.durationMs) && deepEqualsMessages(lhs.orientation, rhs.orientation) && deepEqualsMessages(lhs.isFavorite, rhs.isFavorite) && deepEqualsMessages(lhs.adjustmentTime, rhs.adjustmentTime) && deepEqualsMessages(lhs.latitude, rhs.latitude) && deepEqualsMessages(lhs.longitude, rhs.longitude) && deepEqualsMessages(lhs.playbackStyle, rhs.playbackStyle) && deepEqualsMessages(lhs.burstId, rhs.burstId) && deepEqualsMessages(lhs.isBurstRepresentative, rhs.isBurstRepresentative)
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
@@ -276,6 +290,8 @@ struct PlatformAsset: Hashable {
|
||||
deepHashMessages(value: latitude, hasher: &hasher)
|
||||
deepHashMessages(value: longitude, hasher: &hasher)
|
||||
deepHashMessages(value: playbackStyle, hasher: &hasher)
|
||||
deepHashMessages(value: burstId, hasher: &hasher)
|
||||
deepHashMessages(value: isBurstRepresentative, hasher: &hasher)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,6 +474,78 @@ struct CloudIdResult: Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Generated class from Pigeon that represents data sent in messages.
|
||||
struct BaseResource: Hashable {
|
||||
var path: String
|
||||
var sha1: 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
|
||||
|
||||
return BaseResource(
|
||||
path: path,
|
||||
sha1: sha1
|
||||
)
|
||||
}
|
||||
func toList() -> [Any?] {
|
||||
return [
|
||||
path,
|
||||
sha1,
|
||||
]
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine("BaseResource")
|
||||
deepHashMessages(value: path, hasher: &hasher)
|
||||
deepHashMessages(value: sha1, hasher: &hasher)
|
||||
}
|
||||
}
|
||||
|
||||
/// Generated class from Pigeon that represents data sent in messages.
|
||||
struct BaseLivePhoto: Hashable {
|
||||
var still: BaseResource
|
||||
var video: BaseResource? = nil
|
||||
|
||||
|
||||
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||
static func fromList(_ pigeonVar_list: [Any?]) -> BaseLivePhoto? {
|
||||
let still = pigeonVar_list[0] as! BaseResource
|
||||
let video: BaseResource? = nilOrValue(pigeonVar_list[1])
|
||||
|
||||
return BaseLivePhoto(
|
||||
still: still,
|
||||
video: video
|
||||
)
|
||||
}
|
||||
func toList() -> [Any?] {
|
||||
return [
|
||||
still,
|
||||
video,
|
||||
]
|
||||
}
|
||||
static func == (lhs: BaseLivePhoto, rhs: BaseLivePhoto) -> Bool {
|
||||
if Swift.type(of: lhs) != Swift.type(of: rhs) {
|
||||
return false
|
||||
}
|
||||
return deepEqualsMessages(lhs.still, rhs.still) && deepEqualsMessages(lhs.video, rhs.video)
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine("BaseLivePhoto")
|
||||
deepHashMessages(value: still, hasher: &hasher)
|
||||
deepHashMessages(value: video, hasher: &hasher)
|
||||
}
|
||||
}
|
||||
|
||||
private class MessagesPigeonCodecReader: FlutterStandardReader {
|
||||
override func readValue(ofType type: UInt8) -> Any? {
|
||||
switch type {
|
||||
@@ -468,15 +556,25 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
|
||||
}
|
||||
return nil
|
||||
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:
|
||||
return PlatformAlbum.fromList(self.readValue() as! [Any?])
|
||||
return PlatformAsset.fromList(self.readValue() as! [Any?])
|
||||
case 132:
|
||||
return SyncDelta.fromList(self.readValue() as! [Any?])
|
||||
return PlatformAlbum.fromList(self.readValue() as! [Any?])
|
||||
case 133:
|
||||
return HashResult.fromList(self.readValue() as! [Any?])
|
||||
return SyncDelta.fromList(self.readValue() as! [Any?])
|
||||
case 134:
|
||||
return HashResult.fromList(self.readValue() as! [Any?])
|
||||
case 135:
|
||||
return CloudIdResult.fromList(self.readValue() as! [Any?])
|
||||
case 136:
|
||||
return BaseResource.fromList(self.readValue() as! [Any?])
|
||||
case 137:
|
||||
return BaseLivePhoto.fromList(self.readValue() as! [Any?])
|
||||
default:
|
||||
return super.readValue(ofType: type)
|
||||
}
|
||||
@@ -488,21 +586,30 @@ private class MessagesPigeonCodecWriter: FlutterStandardWriter {
|
||||
if let value = value as? PlatformAssetPlaybackStyle {
|
||||
super.writeByte(129)
|
||||
super.writeValue(value.rawValue)
|
||||
} else if let value = value as? PlatformAsset {
|
||||
} else if let value = value as? EditState {
|
||||
super.writeByte(130)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? PlatformAlbum {
|
||||
super.writeValue(value.rawValue)
|
||||
} else if let value = value as? PlatformAsset {
|
||||
super.writeByte(131)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? SyncDelta {
|
||||
} else if let value = value as? PlatformAlbum {
|
||||
super.writeByte(132)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? HashResult {
|
||||
} else if let value = value as? SyncDelta {
|
||||
super.writeByte(133)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? CloudIdResult {
|
||||
} else if let value = value as? HashResult {
|
||||
super.writeByte(134)
|
||||
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 if let value = value as? BaseLivePhoto {
|
||||
super.writeByte(137)
|
||||
super.writeValue(value.toList())
|
||||
} else {
|
||||
super.writeValue(value)
|
||||
}
|
||||
@@ -540,6 +647,16 @@ protocol NativeSyncApi {
|
||||
func getTrashedAssets() throws -> [String: [PlatformAsset]]
|
||||
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void)
|
||||
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
|
||||
func getBaseResource(assetId: String, allowNetworkAccess: Bool, completion: @escaping (Result<BaseResource?, Error>) -> Void)
|
||||
/// Streams the bytes immich treats as the asset's canonical content — the same
|
||||
/// resource [hashAssets] hashes (`PHAsset.getResource()`, the `.isCurrent`
|
||||
/// rendition). Used to upload iOS burst members: they're invisible to
|
||||
/// photo_manager, so this is the only way to read their file, and streaming
|
||||
/// the same resource the hash measured keeps the server checksum aligned with
|
||||
/// the local one (else the asset shows cloud-only). iOS-only; android returns null.
|
||||
func getCurrentResource(assetId: String, allowNetworkAccess: Bool, completion: @escaping (Result<BaseResource?, Error>) -> Void)
|
||||
func getEditState(assetId: String, allowNetworkAccess: Bool, completion: @escaping (Result<EditState, Error>) -> Void)
|
||||
func getBaseLivePhoto(assetId: String, allowNetworkAccess: Bool, completion: @escaping (Result<BaseLivePhoto?, Error>) -> Void)
|
||||
}
|
||||
|
||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||
@@ -773,5 +890,91 @@ class NativeSyncApiSetup {
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
/// Streams the bytes immich treats as the asset's canonical content — the same
|
||||
/// resource [hashAssets] hashes (`PHAsset.getResource()`, the `.isCurrent`
|
||||
/// rendition). Used to upload iOS burst members: they're invisible to
|
||||
/// photo_manager, so this is the only way to read their file, and streaming
|
||||
/// the same resource the hash measured keeps the server checksum aligned with
|
||||
/// the local one (else the asset shows cloud-only). iOS-only; android returns null.
|
||||
let getCurrentResourceChannel = taskQueue == nil
|
||||
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCurrentResource\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCurrentResource\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||
if let api = api {
|
||||
getCurrentResourceChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let assetIdArg = args[0] as! String
|
||||
let allowNetworkAccessArg = args[1] as! Bool
|
||||
api.getCurrentResource(assetId: assetIdArg, allowNetworkAccess: allowNetworkAccessArg) { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
getCurrentResourceChannel.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)
|
||||
}
|
||||
let getBaseLivePhotoChannel = taskQueue == nil
|
||||
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseLivePhoto\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseLivePhoto\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||
if let api = api {
|
||||
getBaseLivePhotoChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let assetIdArg = args[0] as! String
|
||||
let allowNetworkAccessArg = args[1] as! Bool
|
||||
api.getBaseLivePhoto(assetId: assetIdArg, allowNetworkAccess: allowNetworkAccessArg) { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
getBaseLivePhotoChannel.setMessageHandler(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Photos
|
||||
import CryptoKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct AssetWrapper: Hashable, Equatable {
|
||||
let asset: PlatformAsset
|
||||
@@ -115,7 +116,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
let options = PHFetchOptions()
|
||||
options.sortDescriptors = [NSSortDescriptor(key: "modificationDate", ascending: false)]
|
||||
options.includeHiddenAssets = false
|
||||
|
||||
|
||||
let assets = getAssetsFromAlbum(in: album, options: options)
|
||||
|
||||
let isCloud = album.assetCollectionSubtype == .albumCloudShared || album.assetCollectionSubtype == .albumMyPhotoStream
|
||||
@@ -175,13 +176,14 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
deletedAssets.formUnion(details.deletedLocalIdentifiers)
|
||||
|
||||
if (updated.isEmpty) { continue }
|
||||
|
||||
|
||||
let options = PHFetchOptions()
|
||||
options.includeHiddenAssets = false
|
||||
options.includeAllBurstAssets = true
|
||||
let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options)
|
||||
for i in 0..<result.count {
|
||||
let asset = result.object(at: i)
|
||||
|
||||
|
||||
// Asset wrapper only uses the id for comparison. Multiple change can contain the same asset, skip duplicate changes
|
||||
let predicate = PlatformAsset(
|
||||
id: asset.localIdentifier,
|
||||
@@ -190,14 +192,28 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
durationMs: 0,
|
||||
orientation: 0,
|
||||
isFavorite: false,
|
||||
playbackStyle: .unknown
|
||||
playbackStyle: .unknown,
|
||||
isBurstRepresentative: false
|
||||
)
|
||||
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
let domainAsset = AssetWrapper(with: asset.toPlatformAsset())
|
||||
updatedAssets.insert(domainAsset)
|
||||
|
||||
// iOS reports only the representative in change details, so a delta sync
|
||||
// would otherwise miss the other frames. Pull the full burst group
|
||||
// explicitly and add each member (deduped by the wrapper's id).
|
||||
if let burstId = asset.burstIdentifier {
|
||||
let burstOptions = PHFetchOptions()
|
||||
burstOptions.includeHiddenAssets = false
|
||||
burstOptions.includeAllBurstAssets = true
|
||||
let members = PHAsset.fetchAssets(withBurstIdentifier: burstId, options: burstOptions)
|
||||
members.enumerateObjects { (member, _, _) in
|
||||
updatedAssets.insert(AssetWrapper(with: member.toPlatformAsset()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,7 +325,12 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
var missingAssetIds = Set(assetIds)
|
||||
var assets = [PHAsset]()
|
||||
assets.reserveCapacity(assetIds.count)
|
||||
PHAsset.fetchAssets(withLocalIdentifiers: assetIds, options: nil).enumerateObjects { (asset, _, stop) in
|
||||
// includeAllBurstAssets: a non-representative burst member is invisible to a
|
||||
// default fetch-by-id, so without this it'd be reported "not found" and never
|
||||
// hashed — leaving it out of the backup candidate set permanently.
|
||||
let hashFetchOptions = PHFetchOptions()
|
||||
hashFetchOptions.includeAllBurstAssets = true
|
||||
PHAsset.fetchAssets(withLocalIdentifiers: assetIds, options: hashFetchOptions).enumerateObjects { (asset, _, stop) in
|
||||
if Task.isCancelled {
|
||||
stop.pointee = true
|
||||
return
|
||||
@@ -445,6 +466,11 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
}
|
||||
|
||||
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> {
|
||||
// Surface every burst member, not just the auto-picked representative. Set in
|
||||
// the shared fetch helper so album asset counts and the asset lists stay
|
||||
// consistent — LocalSyncService compares assetCount against the synced set, so
|
||||
// a count that excludes burst members while the list includes them wedges sync.
|
||||
options.includeAllBurstAssets = true
|
||||
// Ensure to actually getting all assets for the Recents album
|
||||
if (album.assetCollectionSubtype == .smartAlbumUserLibrary) {
|
||||
return PHAsset.fetchAssets(with: options)
|
||||
@@ -476,4 +502,334 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
}
|
||||
return mappings;
|
||||
}
|
||||
|
||||
// Streams the asset's current rendition — the same resource hashAssets hashes
|
||||
// (getResource(): the isCurrent rendition, the lone .photo otherwise). Used for
|
||||
// iOS burst members, which photo_manager can't resolve by id; streaming the same
|
||||
// bytes the hash measured keeps the server checksum aligned with the local one
|
||||
// (else the asset shows cloud-only). includeAllBurstAssets so a non-rep resolves.
|
||||
func getCurrentResource(
|
||||
assetId: String,
|
||||
allowNetworkAccess: Bool,
|
||||
completion: @escaping (Result<BaseResource?, Error>) -> Void
|
||||
) {
|
||||
Task { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let options = PHFetchOptions()
|
||||
options.includeAllBurstAssets = true
|
||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: options).firstObject,
|
||||
let resource = asset.getResource()
|
||||
else {
|
||||
return self.completeWhenActive(for: completion, with: .success(nil))
|
||||
}
|
||||
do {
|
||||
let result = try await self.streamBaseResource(
|
||||
resource: resource,
|
||||
localId: assetId,
|
||||
allowNetworkAccess: allowNetworkAccess
|
||||
)
|
||||
self.completeWhenActive(for: completion, with: .success(result))
|
||||
} catch {
|
||||
self.completeWhenActive(for: completion, with: .failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getBaseResource(
|
||||
assetId: String,
|
||||
allowNetworkAccess: Bool,
|
||||
completion: @escaping (Result<BaseResource?, Error>) -> Void
|
||||
) {
|
||||
Task { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
do {
|
||||
guard let originals = try await Self.originalsForEditedAsset(assetId, allowNetworkAccess: allowNetworkAccess)
|
||||
else {
|
||||
return self.completeWhenActive(for: completion, with: .success(nil))
|
||||
}
|
||||
let result = try await self.streamBaseResource(
|
||||
resource: originals.still,
|
||||
localId: assetId,
|
||||
allowNetworkAccess: allowNetworkAccess
|
||||
)
|
||||
self.completeWhenActive(for: completion, with: .success(result))
|
||||
} catch {
|
||||
self.completeWhenActive(for: completion, with: .failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reads both readable originals of an edited live photo (still + paired video) so the
|
||||
// backup can upload the unedited pair and stack the edit onto it. Same edited-only gate
|
||||
// as getBaseResource. video is nil when the asset has no paired video left to recover
|
||||
// (e.g. the edit turned Live off); the still temp is removed if the video read fails.
|
||||
func getBaseLivePhoto(
|
||||
assetId: String,
|
||||
allowNetworkAccess: Bool,
|
||||
completion: @escaping (Result<BaseLivePhoto?, Error>) -> Void
|
||||
) {
|
||||
Task { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
do {
|
||||
guard let originals = try await Self.originalsForEditedAsset(assetId, allowNetworkAccess: allowNetworkAccess)
|
||||
else {
|
||||
return self.completeWhenActive(for: completion, with: .success(nil))
|
||||
}
|
||||
let still = try await self.streamBaseResource(
|
||||
resource: originals.still,
|
||||
localId: assetId,
|
||||
allowNetworkAccess: allowNetworkAccess
|
||||
)
|
||||
var video: BaseResource? = nil
|
||||
if let videoRes = originals.video {
|
||||
do {
|
||||
video = try await self.streamBaseResource(
|
||||
resource: videoRes,
|
||||
localId: assetId,
|
||||
allowNetworkAccess: allowNetworkAccess
|
||||
)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(atPath: still.path)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
self.completeWhenActive(for: completion, with: .success(BaseLivePhoto(still: still, video: video)))
|
||||
} 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
|
||||
|
||||
// Idle deadline for the base-resource reads: cancel only after this long with no
|
||||
// data received, so a stalled iCloud fetch can't hang the backup forever but a
|
||||
// big original on a slow link keeps downloading as long as chunks flow.
|
||||
private static let kBaseReadTimeoutSeconds: Double = 120
|
||||
|
||||
private final class ResourceRequestRef {
|
||||
var id: PHAssetResourceDataRequestID?
|
||||
// Written from the resource callback queue, read from the deadline timer;
|
||||
// unsynchronized on purpose — the read below clamps, so the worst case is
|
||||
// the timer re-arming one extra round.
|
||||
var lastActivity = DispatchTime.now()
|
||||
}
|
||||
|
||||
// Re-arming watchdog: fires after `delay`, cancels if nothing arrived for a full
|
||||
// timeout window, otherwise re-arms for the remainder of the window.
|
||||
private static func armIdleDeadline(_ ref: ResourceRequestRef, after delay: Double = kBaseReadTimeoutSeconds) {
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + delay) {
|
||||
guard let id = ref.id else { return }
|
||||
let nowNs = DispatchTime.now().uptimeNanoseconds
|
||||
let lastNs = ref.lastActivity.uptimeNanoseconds
|
||||
// lastActivity can race ahead of the captured now; treat that as activity.
|
||||
let idle = nowNs > lastNs ? Double(nowNs - lastNs) / 1_000_000_000 : 0
|
||||
if idle >= kBaseReadTimeoutSeconds {
|
||||
PHAssetResourceManager.default().cancelDataRequest(id)
|
||||
} else {
|
||||
armIdleDeadline(ref, after: kBaseReadTimeoutSeconds - idle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shared gate for the base readers: fetch the asset, classify the edit from its
|
||||
// adjustment metadata, and pick the original resources. nil = positively nothing
|
||||
// to recover (missing asset, not edited, or no readable original still). An
|
||||
// unreadable plist throws instead — that's "can't tell right now", and Dart
|
||||
// defers the asset rather than uploading the edit standalone for good.
|
||||
private static func originalsForEditedAsset(
|
||||
_ assetId: String,
|
||||
allowNetworkAccess: Bool
|
||||
) async throws -> (still: PHAssetResource, video: PHAssetResource?)? {
|
||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil).firstObject else {
|
||||
return nil
|
||||
}
|
||||
let resources = PHAssetResource.assetResources(for: asset)
|
||||
let state = await classifyEdit(resources: resources, allowNetworkAccess: allowNetworkAccess)
|
||||
if state == .unknown {
|
||||
throw PigeonError(
|
||||
code: "unknownEditState",
|
||||
message: "Could not read adjustment metadata for \(assetId)",
|
||||
details: nil
|
||||
)
|
||||
}
|
||||
guard state == .edited, let still = originalStillResource(resources) else {
|
||||
return nil
|
||||
}
|
||||
return (still, originalPairedVideoResource(resources))
|
||||
}
|
||||
|
||||
// Works out the edit state from Adjustments.plist only (never reads the photo).
|
||||
// adjustmentRenderTypes is the signal: a real edit moves it off the baseline, while a
|
||||
// plain capture, a Photographic Style, and a reverted edit all sit at the baseline. The
|
||||
// editor id is NOT reliable: com.apple.camera authors both styles and some real edits
|
||||
// (e.g. changing the Photographic Style after capture), so we key off the render types
|
||||
// alone. Cleanup and object-removal 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 renderTypes = (plist["adjustmentRenderTypes"] as? NSNumber)?.intValue
|
||||
let isUserEdit = renderTypes != nil && renderTypes != kNoEditRenderTypes
|
||||
return isUserEdit ? .edited : .notEdited
|
||||
}
|
||||
|
||||
// The unedited original still, told apart from the edited "current" render by isCurrent.
|
||||
// Prefer the non-current .photo; fall back to the .adjustmentBasePhoto flavor some
|
||||
// creation-API / third-party-editor layouts use for the unaltered source (their .photo
|
||||
// IS the edited render, so this must come before the bare .photo net); last, a lone
|
||||
// .photo for single-resource assets or a failed isCurrent read.
|
||||
private static func originalStillResource(_ resources: [PHAssetResource]) -> PHAssetResource? {
|
||||
return resources.first(where: { $0.type == .photo && !$0.isCurrent })
|
||||
?? resources.first(where: { $0.type == .adjustmentBasePhoto })
|
||||
?? resources.first(where: { $0.type == .photo })
|
||||
}
|
||||
|
||||
// The unedited original paired video, same isCurrent / adjustment-base ordering as the
|
||||
// still. nil when the asset carries no paired video (not live, or Live turned off).
|
||||
private static func originalPairedVideoResource(_ resources: [PHAssetResource]) -> PHAssetResource? {
|
||||
return resources.first(where: { $0.type == .pairedVideo && !$0.isCurrent })
|
||||
?? resources.first(where: { $0.type == .adjustmentBasePairedVideo })
|
||||
?? resources.first(where: { $0.type == .pairedVideo })
|
||||
}
|
||||
|
||||
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"
|
||||
// Library/Caches, not tmp: the chain can span launches and clearCache wipes
|
||||
// tmp at the start of every upload run. Swept by clearEditBaseCache instead.
|
||||
let cacheRoot =
|
||||
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
|
||||
?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
let tempDir = cacheRoot.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 {
|
||||
try? FileManager.default.removeItem(at: tempUrl)
|
||||
throw PigeonError(
|
||||
code: "baseResourceWriteFailed",
|
||||
message: "Failed to open temp file for base resource \(localId)",
|
||||
details: nil
|
||||
)
|
||||
}
|
||||
|
||||
var hasher = Insecure.SHA1()
|
||||
let options = PHAssetResourceRequestOptions()
|
||||
options.isNetworkAccessAllowed = allowNetworkAccess
|
||||
|
||||
// Deadline + cancellation so a stalled iCloud read can't hang the backup forever;
|
||||
// a write failure also cancels right away instead of draining the download for nothing.
|
||||
let requestRef = ResourceRequestRef()
|
||||
let succeeded = await withCheckedContinuation { (continuation: CheckedContinuation<Bool, Never>) in
|
||||
var writeFailed = false
|
||||
requestRef.id = PHAssetResourceManager.default().requestData(
|
||||
for: resource,
|
||||
options: options,
|
||||
dataReceivedHandler: { chunk in
|
||||
requestRef.lastActivity = DispatchTime.now()
|
||||
if writeFailed { return }
|
||||
do {
|
||||
try handle.write(contentsOf: chunk)
|
||||
hasher.update(data: chunk)
|
||||
} catch {
|
||||
writeFailed = true
|
||||
if let id = requestRef.id {
|
||||
PHAssetResourceManager.default().cancelDataRequest(id)
|
||||
}
|
||||
}
|
||||
},
|
||||
completionHandler: { error in
|
||||
requestRef.id = nil
|
||||
continuation.resume(returning: error == nil && !writeFailed)
|
||||
}
|
||||
)
|
||||
Self.armIdleDeadline(requestRef)
|
||||
}
|
||||
|
||||
try? handle.close()
|
||||
|
||||
guard succeeded else {
|
||||
try? FileManager.default.removeItem(at: tempUrl)
|
||||
throw PigeonError(
|
||||
code: "baseResourceReadFailed",
|
||||
message: "Failed to read base resource for \(localId)",
|
||||
details: nil
|
||||
)
|
||||
}
|
||||
|
||||
let sha1 = Data(hasher.finalize()).base64EncodedString()
|
||||
return BaseResource(path: tempUrl.path, sha1: sha1)
|
||||
}
|
||||
|
||||
private static func collectResourceData(
|
||||
_ resource: PHAssetResource,
|
||||
allowNetworkAccess: Bool
|
||||
) async -> Data? {
|
||||
let options = PHAssetResourceRequestOptions()
|
||||
options.isNetworkAccessAllowed = allowNetworkAccess
|
||||
var buffer = Data()
|
||||
let requestRef = ResourceRequestRef()
|
||||
return await withCheckedContinuation { (continuation: CheckedContinuation<Data?, Never>) in
|
||||
requestRef.id = PHAssetResourceManager.default().requestData(
|
||||
for: resource,
|
||||
options: options,
|
||||
dataReceivedHandler: { data in
|
||||
requestRef.lastActivity = DispatchTime.now()
|
||||
buffer.append(data)
|
||||
},
|
||||
completionHandler: { error in
|
||||
requestRef.id = nil
|
||||
continuation.resume(returning: error == nil ? buffer : nil)
|
||||
}
|
||||
)
|
||||
armIdleDeadline(requestRef)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -27,7 +27,9 @@ extension PHAsset {
|
||||
adjustmentTime: adjustmentTimestamp,
|
||||
latitude: location?.coordinate.latitude,
|
||||
longitude: location?.coordinate.longitude,
|
||||
playbackStyle: platformPlaybackStyle
|
||||
playbackStyle: platformPlaybackStyle,
|
||||
burstId: burstIdentifier,
|
||||
isBurstRepresentative: representsBurst
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,22 @@ const String kSecuredPinCode = "secured_pin_code";
|
||||
const String kManualUploadGroup = 'manual_upload_group';
|
||||
const String kBackupGroup = 'backup_group';
|
||||
const String kBackupLivePhotoGroup = 'backup_live_photo_group';
|
||||
const String kBackupEditPairGroup = 'backup_edit_pair_group';
|
||||
|
||||
// Upload multipart 'visibility' value for motion videos (server AssetVisibility.Hidden)
|
||||
// so they never flash onto the timeline before their still links them.
|
||||
const String kHiddenVisibility = 'hidden';
|
||||
|
||||
// Server's 400 message when stackParentId points at a trashed/deleted asset
|
||||
// (asset-media.service.ts). Matching it clears the stale prior stamps so the
|
||||
// next backup cycle re-resolves instead of looping on the same dead id.
|
||||
const String kDeadStackParentError = 'Cannot stack onto a trashed or missing asset';
|
||||
|
||||
// Multipart fields that stack a burst frame under its representative without
|
||||
// letting it steal the cover (server keepPrimary, asset-media.service.ts).
|
||||
// Empty when there's no anchor yet (rep-less group → standalone upload).
|
||||
Map<String, String> burstStackFields(String? anchorRemoteId) =>
|
||||
anchorRemoteId != null ? {'stackParentId': anchorRemoteId, 'keepPrimary': 'true'} : const {};
|
||||
const String kDownloadGroupImage = 'group_image';
|
||||
const String kDownloadGroupVideo = 'group_video';
|
||||
const String kDownloadGroupLivePhoto = 'group_livephoto';
|
||||
|
||||
@@ -12,6 +12,19 @@ class LocalAsset extends BaseAsset {
|
||||
final double? latitude;
|
||||
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;
|
||||
|
||||
// iOS burst grouping. burstId = PHAsset.burstIdentifier (null for non-burst).
|
||||
// isBurstRepresentative = the auto-picked lead frame (timeline tile + stack
|
||||
// anchor).
|
||||
final String? burstId;
|
||||
final bool isBurstRepresentative;
|
||||
|
||||
const LocalAsset({
|
||||
required this.id,
|
||||
String? remoteId,
|
||||
@@ -32,6 +45,10 @@ class LocalAsset extends BaseAsset {
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
required super.isEdited,
|
||||
this.priorRemoteId,
|
||||
this.syncedChecksum,
|
||||
this.burstId,
|
||||
this.isBurstRepresentative = false,
|
||||
}) : remoteAssetId = remoteId;
|
||||
|
||||
@override
|
||||
@@ -120,6 +137,10 @@ class LocalAsset extends BaseAsset {
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
bool? isEdited,
|
||||
String? priorRemoteId,
|
||||
String? syncedChecksum,
|
||||
String? burstId,
|
||||
bool? isBurstRepresentative,
|
||||
}) {
|
||||
return LocalAsset(
|
||||
id: id ?? this.id,
|
||||
@@ -140,6 +161,10 @@ class LocalAsset extends BaseAsset {
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
isEdited: isEdited ?? this.isEdited,
|
||||
priorRemoteId: priorRemoteId ?? this.priorRemoteId,
|
||||
syncedChecksum: syncedChecksum ?? this.syncedChecksum,
|
||||
burstId: burstId ?? this.burstId,
|
||||
isBurstRepresentative: isBurstRepresentative ?? this.isBurstRepresentative,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ class RemoteAsset extends BaseAsset {
|
||||
final DateTime? uploadedAt;
|
||||
final DateTime? deletedAt;
|
||||
|
||||
// The linked local's current checksum. Differs from [checksum] when the link
|
||||
// came via priorRemoteId (the local re-encoded on device, e.g. a revert); local
|
||||
// renders are cache-keyed by this so on-device changes aren't shown stale.
|
||||
final String? localChecksum;
|
||||
|
||||
const RemoteAsset({
|
||||
required this.id,
|
||||
String? localId,
|
||||
@@ -33,6 +38,7 @@ class RemoteAsset extends BaseAsset {
|
||||
this.stackId,
|
||||
required super.isEdited,
|
||||
this.deletedAt,
|
||||
this.localChecksum,
|
||||
}) : localAssetId = localId;
|
||||
|
||||
@override
|
||||
@@ -91,7 +97,8 @@ class RemoteAsset extends BaseAsset {
|
||||
visibility == other.visibility &&
|
||||
stackId == other.stackId &&
|
||||
uploadedAt == other.uploadedAt &&
|
||||
deletedAt == other.deletedAt;
|
||||
deletedAt == other.deletedAt &&
|
||||
localChecksum == other.localChecksum;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -104,7 +111,8 @@ class RemoteAsset extends BaseAsset {
|
||||
visibility.hashCode ^
|
||||
stackId.hashCode ^
|
||||
uploadedAt.hashCode ^
|
||||
deletedAt.hashCode;
|
||||
deletedAt.hashCode ^
|
||||
localChecksum.hashCode;
|
||||
|
||||
RemoteAsset copyWith({
|
||||
String? id,
|
||||
@@ -126,6 +134,7 @@ class RemoteAsset extends BaseAsset {
|
||||
String? stackId,
|
||||
bool? isEdited,
|
||||
DateTime? deletedAt,
|
||||
String? localChecksum,
|
||||
}) {
|
||||
return RemoteAsset(
|
||||
id: id ?? this.id,
|
||||
@@ -147,6 +156,7 @@ class RemoteAsset extends BaseAsset {
|
||||
stackId: stackId ?? this.stackId,
|
||||
isEdited: isEdited ?? this.isEdited,
|
||||
deletedAt: deletedAt ?? this.deletedAt,
|
||||
localChecksum: localChecksum ?? this.localChecksum,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -174,6 +184,7 @@ class RemoteAssetExif extends RemoteAsset {
|
||||
super.livePhotoVideoId,
|
||||
super.stackId,
|
||||
super.isEdited = false,
|
||||
super.localChecksum,
|
||||
this.exifInfo = const ExifInfo(),
|
||||
});
|
||||
|
||||
@@ -212,6 +223,7 @@ class RemoteAssetExif extends RemoteAsset {
|
||||
String? livePhotoVideoId,
|
||||
String? stackId,
|
||||
bool? isEdited,
|
||||
String? localChecksum,
|
||||
ExifInfo? exifInfo,
|
||||
}) {
|
||||
return RemoteAssetExif(
|
||||
@@ -234,6 +246,7 @@ class RemoteAssetExif extends RemoteAsset {
|
||||
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
|
||||
stackId: stackId ?? this.stackId,
|
||||
isEdited: isEdited ?? this.isEdited,
|
||||
localChecksum: localChecksum ?? this.localChecksum,
|
||||
exifInfo: exifInfo ?? this.exifInfo, // Use the new parameter
|
||||
);
|
||||
}
|
||||
|
||||
@@ -268,7 +268,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
return _ref?.read(driftBackupProvider.notifier).startBackupWithURLSession(currentUser.id);
|
||||
}
|
||||
|
||||
return _ref
|
||||
await _ref
|
||||
?.read(foregroundUploadServiceProvider)
|
||||
.uploadCandidates(currentUser.id, _cancellationToken, useSequentialUpload: true);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
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 this._nativeSyncApi,
|
||||
required this._stackRepository,
|
||||
required this._localAssetRepository,
|
||||
required this._assetApiRepository,
|
||||
});
|
||||
|
||||
/// Returns the remote id the stack cover was flipped back to when the asset
|
||||
/// was a revert and was handled (caller skips the upload and can report that
|
||||
/// id); null to fall through to the normal upload path.
|
||||
Future<String?> tryHandleRevert(LocalAsset asset) async {
|
||||
if (asset.priorRemoteId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
if (editState != EditState.notEdited) {
|
||||
return null;
|
||||
}
|
||||
} catch (error, stack) {
|
||||
_log.warning("edit-state probe failed for ${asset.id}", error, stack);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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 null;
|
||||
}
|
||||
final base = await _stackRepository.findStackBaseId(foundStack, excludeId: asset.priorRemoteId!);
|
||||
if (base == null) {
|
||||
return null;
|
||||
}
|
||||
stackId = foundStack;
|
||||
baseId = base;
|
||||
} catch (error, stack) {
|
||||
_log.warning("revert stack lookup failed for ${asset.id}", error, stack);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await _assetApiRepository.setStackPrimary(stackId, baseId);
|
||||
} catch (error, stack) {
|
||||
_log.warning("revert primary flip failed for ${asset.id}", error, stack);
|
||||
return null;
|
||||
}
|
||||
|
||||
// The server flip is what makes the revert handled. If the local writes fail,
|
||||
// falling through would upload the reverted render as a brand-new edit — the
|
||||
// opposite of the user's action — so log and let checkpoint sync heal local state.
|
||||
try {
|
||||
await _stackRepository.setPrimary(stackId, baseId);
|
||||
await _localAssetRepository.markSynced(asset.id, priorRemoteId: baseId, syncedChecksum: asset.checksum);
|
||||
} catch (error, stack) {
|
||||
_log.warning("revert local reconcile failed for ${asset.id}", error, stack);
|
||||
}
|
||||
|
||||
return baseId;
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,10 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.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_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/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
const String _kHashCancelledCode = "HASH_CANCELLED";
|
||||
@@ -20,6 +22,8 @@ class HashService {
|
||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final Completer<void>? _cancellation;
|
||||
final DriftStackRepository _stackRepository;
|
||||
final AssetApiRepository _assetApiRepository;
|
||||
final _log = Logger('HashService');
|
||||
|
||||
HashService({
|
||||
@@ -28,6 +32,8 @@ class HashService {
|
||||
required this._trashedLocalAssetRepository,
|
||||
required this._nativeSyncApi,
|
||||
this._cancellation,
|
||||
required this._stackRepository,
|
||||
required this._assetApiRepository,
|
||||
int? batchSize,
|
||||
}) : _batchSize = batchSize ?? kBatchHashFileLimit {
|
||||
// Stop the in-flight native hash call promptly on cancellation; the loops
|
||||
@@ -66,6 +72,17 @@ class HashService {
|
||||
await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true);
|
||||
}
|
||||
}
|
||||
|
||||
// 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). Runs every cycle, not just when something
|
||||
// hashed: a flip that failed (offline at hash time) has no second hash to ride,
|
||||
// and the stack-driven target query is cheap and self-limiting.
|
||||
if (CurrentPlatform.isIOS && !isCancelled) {
|
||||
await _reconcileReverts();
|
||||
}
|
||||
} on PlatformException catch (e) {
|
||||
if (e.code == _kHashCancelledCode) {
|
||||
_log.warning("Hashing cancelled by platform");
|
||||
@@ -143,4 +160,30 @@ class HashService {
|
||||
await _localAssetRepository.updateHashes(hashed);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reconcileReverts() async {
|
||||
final List<StackReconcileTarget> targets;
|
||||
try {
|
||||
targets = await _stackRepository.findRevertReconcileTargets();
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,6 +469,8 @@ extension PlatformToLocalAsset on PlatformAsset {
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
isEdited: false,
|
||||
burstId: burstId,
|
||||
isBurstRepresentative: isBurstRepresentative,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -280,7 +280,8 @@ class SyncStreamService {
|
||||
return;
|
||||
// SyncCompleteV1 is used to signal the completion of the sync process. Cleanup stale assets and signal completion
|
||||
case SyncEntityType.syncCompleteV1:
|
||||
return _syncStreamRepository.pruneAssets();
|
||||
return;
|
||||
// return _syncStreamRepository.pruneAssets();
|
||||
// Request to reset the client state. Clear everything related to remote entities
|
||||
case SyncEntityType.syncResetV1:
|
||||
return _syncStreamRepository.reset();
|
||||
@@ -359,7 +360,7 @@ class SyncStreamService {
|
||||
}
|
||||
|
||||
if (assets.isNotEmpty && exifs.isNotEmpty) {
|
||||
await _syncStreamRepository.updateAssetsV1(assets, debugLabel: 'websocket-batch');
|
||||
await _syncStreamRepository.updateAssetsV1(assets, debugLabel: 'websocket-batch', fromWebsocket: true);
|
||||
await _syncStreamRepository.updateAssetsExifV1(exifs, debugLabel: 'websocket-batch');
|
||||
_logger.info('Successfully processed ${assets.length} assets in batch');
|
||||
}
|
||||
@@ -402,7 +403,7 @@ class SyncStreamService {
|
||||
}
|
||||
|
||||
if (assets.isNotEmpty && exifs.isNotEmpty) {
|
||||
await _syncStreamRepository.updateAssetsV2(assets, debugLabel: 'websocket-batch');
|
||||
await _syncStreamRepository.updateAssetsV2(assets, debugLabel: 'websocket-batch', fromWebsocket: true);
|
||||
await _syncStreamRepository.updateAssetsExifV1(exifs, debugLabel: 'websocket-batch');
|
||||
_logger.info('Successfully processed ${assets.length} assets in batch');
|
||||
}
|
||||
@@ -443,7 +444,7 @@ class SyncStreamService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
await _syncStreamRepository.updateAssetsV1([asset], debugLabel: 'websocket-edit');
|
||||
await _syncStreamRepository.updateAssetsV1([asset], debugLabel: 'websocket-edit', fromWebsocket: true);
|
||||
await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit');
|
||||
|
||||
_logger.info(
|
||||
@@ -481,7 +482,7 @@ class SyncStreamService {
|
||||
.whereType<SyncAssetEditV1>()
|
||||
.toList();
|
||||
|
||||
await _syncStreamRepository.updateAssetsV2([asset], debugLabel: 'websocket-edit');
|
||||
await _syncStreamRepository.updateAssetsV2([asset], debugLabel: 'websocket-edit', fromWebsocket: true);
|
||||
await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit');
|
||||
|
||||
_logger.info(
|
||||
|
||||
@@ -160,6 +160,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) {
|
||||
if (_syncWebsocketTask != null) {
|
||||
return _syncWebsocketTask!.future;
|
||||
|
||||
@@ -7,6 +7,8 @@ import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)')
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)')
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)')
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_prior_remote_id ON local_asset_entity (prior_remote_id)')
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_burst_id ON local_asset_entity (burst_id)')
|
||||
class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
||||
const LocalAssetEntity();
|
||||
|
||||
@@ -28,6 +30,21 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
||||
|
||||
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()();
|
||||
|
||||
// iOS burst grouping. burstId = PHAsset.burstIdentifier (null for non-burst).
|
||||
// isBurstRepresentative = the auto-picked lead frame at detection; the rep is
|
||||
// the timeline tile and the stack anchor. Both re-sync on every delta, so a
|
||||
// Photos re-pick that moves the rep flag is reflected.
|
||||
TextColumn get burstId => text().nullable()();
|
||||
BoolColumn get isBurstRepresentative => boolean().withDefault(const Constant(false))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
@@ -52,5 +69,9 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
|
||||
longitude: longitude,
|
||||
cloudId: iCloudId,
|
||||
isEdited: false,
|
||||
priorRemoteId: priorRemoteId,
|
||||
syncedChecksum: syncedChecksum,
|
||||
burstId: burstId,
|
||||
isBurstRepresentative: isBurstRepresentative,
|
||||
);
|
||||
}
|
||||
|
||||
+304
-3
@@ -26,6 +26,10 @@ typedef $$LocalAssetEntityTableCreateCompanionBuilder =
|
||||
i0.Value<double?> latitude,
|
||||
i0.Value<double?> longitude,
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
||||
i0.Value<String?> priorRemoteId,
|
||||
i0.Value<String?> syncedChecksum,
|
||||
i0.Value<String?> burstId,
|
||||
i0.Value<bool> isBurstRepresentative,
|
||||
});
|
||||
typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
|
||||
i1.LocalAssetEntityCompanion Function({
|
||||
@@ -45,6 +49,10 @@ typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
|
||||
i0.Value<double?> latitude,
|
||||
i0.Value<double?> longitude,
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
||||
i0.Value<String?> priorRemoteId,
|
||||
i0.Value<String?> syncedChecksum,
|
||||
i0.Value<String?> burstId,
|
||||
i0.Value<bool> isBurstRepresentative,
|
||||
});
|
||||
|
||||
class $$LocalAssetEntityTableFilterComposer
|
||||
@@ -141,6 +149,26 @@ class $$LocalAssetEntityTableFilterComposer
|
||||
column: $table.playbackStyle,
|
||||
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),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<String> get burstId => $composableBuilder(
|
||||
column: $table.burstId,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<bool> get isBurstRepresentative => $composableBuilder(
|
||||
column: $table.isBurstRepresentative,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$LocalAssetEntityTableOrderingComposer
|
||||
@@ -231,6 +259,26 @@ class $$LocalAssetEntityTableOrderingComposer
|
||||
column: $table.playbackStyle,
|
||||
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),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<String> get burstId => $composableBuilder(
|
||||
column: $table.burstId,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<bool> get isBurstRepresentative => $composableBuilder(
|
||||
column: $table.isBurstRepresentative,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$LocalAssetEntityTableAnnotationComposer
|
||||
@@ -300,6 +348,24 @@ class $$LocalAssetEntityTableAnnotationComposer
|
||||
column: $table.playbackStyle,
|
||||
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,
|
||||
);
|
||||
|
||||
i0.GeneratedColumn<String> get burstId =>
|
||||
$composableBuilder(column: $table.burstId, builder: (column) => column);
|
||||
|
||||
i0.GeneratedColumn<bool> get isBurstRepresentative => $composableBuilder(
|
||||
column: $table.isBurstRepresentative,
|
||||
builder: (column) => column,
|
||||
);
|
||||
}
|
||||
|
||||
class $$LocalAssetEntityTableTableManager
|
||||
@@ -359,6 +425,10 @@ class $$LocalAssetEntityTableTableManager
|
||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
||||
const i0.Value.absent(),
|
||||
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
|
||||
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
|
||||
i0.Value<String?> burstId = const i0.Value.absent(),
|
||||
i0.Value<bool> isBurstRepresentative = const i0.Value.absent(),
|
||||
}) => i1.LocalAssetEntityCompanion(
|
||||
name: name,
|
||||
type: type,
|
||||
@@ -376,6 +446,10 @@ class $$LocalAssetEntityTableTableManager
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
playbackStyle: playbackStyle,
|
||||
priorRemoteId: priorRemoteId,
|
||||
syncedChecksum: syncedChecksum,
|
||||
burstId: burstId,
|
||||
isBurstRepresentative: isBurstRepresentative,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
@@ -396,6 +470,10 @@ class $$LocalAssetEntityTableTableManager
|
||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
||||
const i0.Value.absent(),
|
||||
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
|
||||
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
|
||||
i0.Value<String?> burstId = const i0.Value.absent(),
|
||||
i0.Value<bool> isBurstRepresentative = const i0.Value.absent(),
|
||||
}) => i1.LocalAssetEntityCompanion.insert(
|
||||
name: name,
|
||||
type: type,
|
||||
@@ -413,6 +491,10 @@ class $$LocalAssetEntityTableTableManager
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
playbackStyle: playbackStyle,
|
||||
priorRemoteId: priorRemoteId,
|
||||
syncedChecksum: syncedChecksum,
|
||||
burstId: burstId,
|
||||
isBurstRepresentative: isBurstRepresentative,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
||||
@@ -637,6 +719,54 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
).withConverter<i2.AssetPlaybackStyle>(
|
||||
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,
|
||||
);
|
||||
static const i0.VerificationMeta _burstIdMeta = const i0.VerificationMeta(
|
||||
'burstId',
|
||||
);
|
||||
@override
|
||||
late final i0.GeneratedColumn<String> burstId = i0.GeneratedColumn<String>(
|
||||
'burst_id',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i0.DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const i0.VerificationMeta _isBurstRepresentativeMeta =
|
||||
const i0.VerificationMeta('isBurstRepresentative');
|
||||
@override
|
||||
late final i0.GeneratedColumn<bool> isBurstRepresentative =
|
||||
i0.GeneratedColumn<bool>(
|
||||
'is_burst_representative',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i0.DriftSqlType.bool,
|
||||
requiredDuringInsert: false,
|
||||
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("is_burst_representative" IN (0, 1))',
|
||||
),
|
||||
defaultValue: const i4.Constant(false),
|
||||
);
|
||||
@override
|
||||
List<i0.GeneratedColumn> get $columns => [
|
||||
name,
|
||||
@@ -655,6 +785,10 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
latitude,
|
||||
longitude,
|
||||
playbackStyle,
|
||||
priorRemoteId,
|
||||
syncedChecksum,
|
||||
burstId,
|
||||
isBurstRepresentative,
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@@ -759,6 +893,39 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('burst_id')) {
|
||||
context.handle(
|
||||
_burstIdMeta,
|
||||
burstId.isAcceptableOrUnknown(data['burst_id']!, _burstIdMeta),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('is_burst_representative')) {
|
||||
context.handle(
|
||||
_isBurstRepresentativeMeta,
|
||||
isBurstRepresentative.isAcceptableOrUnknown(
|
||||
data['is_burst_representative']!,
|
||||
_isBurstRepresentativeMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -839,6 +1006,22 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
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'],
|
||||
),
|
||||
burstId: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.string,
|
||||
data['${effectivePrefix}burst_id'],
|
||||
),
|
||||
isBurstRepresentative: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.bool,
|
||||
data['${effectivePrefix}is_burst_representative'],
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -877,6 +1060,10 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final i2.AssetPlaybackStyle playbackStyle;
|
||||
final String? priorRemoteId;
|
||||
final String? syncedChecksum;
|
||||
final String? burstId;
|
||||
final bool isBurstRepresentative;
|
||||
const LocalAssetEntityData({
|
||||
required this.name,
|
||||
required this.type,
|
||||
@@ -894,6 +1081,10 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
required this.playbackStyle,
|
||||
this.priorRemoteId,
|
||||
this.syncedChecksum,
|
||||
this.burstId,
|
||||
required this.isBurstRepresentative,
|
||||
});
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
@@ -938,6 +1129,16 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
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);
|
||||
}
|
||||
if (!nullToAbsent || burstId != null) {
|
||||
map['burst_id'] = i0.Variable<String>(burstId);
|
||||
}
|
||||
map['is_burst_representative'] = i0.Variable<bool>(isBurstRepresentative);
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -967,6 +1168,12 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
playbackStyle: i1.$LocalAssetEntityTable.$converterplaybackStyle.fromJson(
|
||||
serializer.fromJson<int>(json['playbackStyle']),
|
||||
),
|
||||
priorRemoteId: serializer.fromJson<String?>(json['priorRemoteId']),
|
||||
syncedChecksum: serializer.fromJson<String?>(json['syncedChecksum']),
|
||||
burstId: serializer.fromJson<String?>(json['burstId']),
|
||||
isBurstRepresentative: serializer.fromJson<bool>(
|
||||
json['isBurstRepresentative'],
|
||||
),
|
||||
);
|
||||
}
|
||||
@override
|
||||
@@ -993,6 +1200,10 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
'playbackStyle': serializer.toJson<int>(
|
||||
i1.$LocalAssetEntityTable.$converterplaybackStyle.toJson(playbackStyle),
|
||||
),
|
||||
'priorRemoteId': serializer.toJson<String?>(priorRemoteId),
|
||||
'syncedChecksum': serializer.toJson<String?>(syncedChecksum),
|
||||
'burstId': serializer.toJson<String?>(burstId),
|
||||
'isBurstRepresentative': serializer.toJson<bool>(isBurstRepresentative),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1013,6 +1224,10 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
i0.Value<double?> latitude = const i0.Value.absent(),
|
||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||
i2.AssetPlaybackStyle? playbackStyle,
|
||||
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
|
||||
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
|
||||
i0.Value<String?> burstId = const i0.Value.absent(),
|
||||
bool? isBurstRepresentative,
|
||||
}) => i1.LocalAssetEntityData(
|
||||
name: name ?? this.name,
|
||||
type: type ?? this.type,
|
||||
@@ -1032,6 +1247,14 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
latitude: latitude.present ? latitude.value : this.latitude,
|
||||
longitude: longitude.present ? longitude.value : this.longitude,
|
||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
||||
priorRemoteId: priorRemoteId.present
|
||||
? priorRemoteId.value
|
||||
: this.priorRemoteId,
|
||||
syncedChecksum: syncedChecksum.present
|
||||
? syncedChecksum.value
|
||||
: this.syncedChecksum,
|
||||
burstId: burstId.present ? burstId.value : this.burstId,
|
||||
isBurstRepresentative: isBurstRepresentative ?? this.isBurstRepresentative,
|
||||
);
|
||||
LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) {
|
||||
return LocalAssetEntityData(
|
||||
@@ -1061,6 +1284,16 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
playbackStyle: data.playbackStyle.present
|
||||
? data.playbackStyle.value
|
||||
: this.playbackStyle,
|
||||
priorRemoteId: data.priorRemoteId.present
|
||||
? data.priorRemoteId.value
|
||||
: this.priorRemoteId,
|
||||
syncedChecksum: data.syncedChecksum.present
|
||||
? data.syncedChecksum.value
|
||||
: this.syncedChecksum,
|
||||
burstId: data.burstId.present ? data.burstId.value : this.burstId,
|
||||
isBurstRepresentative: data.isBurstRepresentative.present
|
||||
? data.isBurstRepresentative.value
|
||||
: this.isBurstRepresentative,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1082,7 +1315,11 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
..write('adjustmentTime: $adjustmentTime, ')
|
||||
..write('latitude: $latitude, ')
|
||||
..write('longitude: $longitude, ')
|
||||
..write('playbackStyle: $playbackStyle')
|
||||
..write('playbackStyle: $playbackStyle, ')
|
||||
..write('priorRemoteId: $priorRemoteId, ')
|
||||
..write('syncedChecksum: $syncedChecksum, ')
|
||||
..write('burstId: $burstId, ')
|
||||
..write('isBurstRepresentative: $isBurstRepresentative')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
@@ -1105,6 +1342,10 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
latitude,
|
||||
longitude,
|
||||
playbackStyle,
|
||||
priorRemoteId,
|
||||
syncedChecksum,
|
||||
burstId,
|
||||
isBurstRepresentative,
|
||||
);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -1125,7 +1366,11 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
other.adjustmentTime == this.adjustmentTime &&
|
||||
other.latitude == this.latitude &&
|
||||
other.longitude == this.longitude &&
|
||||
other.playbackStyle == this.playbackStyle);
|
||||
other.playbackStyle == this.playbackStyle &&
|
||||
other.priorRemoteId == this.priorRemoteId &&
|
||||
other.syncedChecksum == this.syncedChecksum &&
|
||||
other.burstId == this.burstId &&
|
||||
other.isBurstRepresentative == this.isBurstRepresentative);
|
||||
}
|
||||
|
||||
class LocalAssetEntityCompanion
|
||||
@@ -1146,6 +1391,10 @@ class LocalAssetEntityCompanion
|
||||
final i0.Value<double?> latitude;
|
||||
final i0.Value<double?> longitude;
|
||||
final i0.Value<i2.AssetPlaybackStyle> playbackStyle;
|
||||
final i0.Value<String?> priorRemoteId;
|
||||
final i0.Value<String?> syncedChecksum;
|
||||
final i0.Value<String?> burstId;
|
||||
final i0.Value<bool> isBurstRepresentative;
|
||||
const LocalAssetEntityCompanion({
|
||||
this.name = const i0.Value.absent(),
|
||||
this.type = const i0.Value.absent(),
|
||||
@@ -1163,6 +1412,10 @@ class LocalAssetEntityCompanion
|
||||
this.latitude = const i0.Value.absent(),
|
||||
this.longitude = const i0.Value.absent(),
|
||||
this.playbackStyle = const i0.Value.absent(),
|
||||
this.priorRemoteId = const i0.Value.absent(),
|
||||
this.syncedChecksum = const i0.Value.absent(),
|
||||
this.burstId = const i0.Value.absent(),
|
||||
this.isBurstRepresentative = const i0.Value.absent(),
|
||||
});
|
||||
LocalAssetEntityCompanion.insert({
|
||||
required String name,
|
||||
@@ -1181,6 +1434,10 @@ class LocalAssetEntityCompanion
|
||||
this.latitude = const i0.Value.absent(),
|
||||
this.longitude = const i0.Value.absent(),
|
||||
this.playbackStyle = const i0.Value.absent(),
|
||||
this.priorRemoteId = const i0.Value.absent(),
|
||||
this.syncedChecksum = const i0.Value.absent(),
|
||||
this.burstId = const i0.Value.absent(),
|
||||
this.isBurstRepresentative = const i0.Value.absent(),
|
||||
}) : name = i0.Value(name),
|
||||
type = i0.Value(type),
|
||||
id = i0.Value(id);
|
||||
@@ -1201,6 +1458,10 @@ class LocalAssetEntityCompanion
|
||||
i0.Expression<double>? latitude,
|
||||
i0.Expression<double>? longitude,
|
||||
i0.Expression<int>? playbackStyle,
|
||||
i0.Expression<String>? priorRemoteId,
|
||||
i0.Expression<String>? syncedChecksum,
|
||||
i0.Expression<String>? burstId,
|
||||
i0.Expression<bool>? isBurstRepresentative,
|
||||
}) {
|
||||
return i0.RawValuesInsertable({
|
||||
if (name != null) 'name': name,
|
||||
@@ -1219,6 +1480,11 @@ class LocalAssetEntityCompanion
|
||||
if (latitude != null) 'latitude': latitude,
|
||||
if (longitude != null) 'longitude': longitude,
|
||||
if (playbackStyle != null) 'playback_style': playbackStyle,
|
||||
if (priorRemoteId != null) 'prior_remote_id': priorRemoteId,
|
||||
if (syncedChecksum != null) 'synced_checksum': syncedChecksum,
|
||||
if (burstId != null) 'burst_id': burstId,
|
||||
if (isBurstRepresentative != null)
|
||||
'is_burst_representative': isBurstRepresentative,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1239,6 +1505,10 @@ class LocalAssetEntityCompanion
|
||||
i0.Value<double?>? latitude,
|
||||
i0.Value<double?>? longitude,
|
||||
i0.Value<i2.AssetPlaybackStyle>? playbackStyle,
|
||||
i0.Value<String?>? priorRemoteId,
|
||||
i0.Value<String?>? syncedChecksum,
|
||||
i0.Value<String?>? burstId,
|
||||
i0.Value<bool>? isBurstRepresentative,
|
||||
}) {
|
||||
return i1.LocalAssetEntityCompanion(
|
||||
name: name ?? this.name,
|
||||
@@ -1257,6 +1527,11 @@ class LocalAssetEntityCompanion
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
||||
priorRemoteId: priorRemoteId ?? this.priorRemoteId,
|
||||
syncedChecksum: syncedChecksum ?? this.syncedChecksum,
|
||||
burstId: burstId ?? this.burstId,
|
||||
isBurstRepresentative:
|
||||
isBurstRepresentative ?? this.isBurstRepresentative,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1317,6 +1592,20 @@ 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);
|
||||
}
|
||||
if (burstId.present) {
|
||||
map['burst_id'] = i0.Variable<String>(burstId.value);
|
||||
}
|
||||
if (isBurstRepresentative.present) {
|
||||
map['is_burst_representative'] = i0.Variable<bool>(
|
||||
isBurstRepresentative.value,
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -1338,7 +1627,11 @@ class LocalAssetEntityCompanion
|
||||
..write('adjustmentTime: $adjustmentTime, ')
|
||||
..write('latitude: $latitude, ')
|
||||
..write('longitude: $longitude, ')
|
||||
..write('playbackStyle: $playbackStyle')
|
||||
..write('playbackStyle: $playbackStyle, ')
|
||||
..write('priorRemoteId: $priorRemoteId, ')
|
||||
..write('syncedChecksum: $syncedChecksum, ')
|
||||
..write('burstId: $burstId, ')
|
||||
..write('isBurstRepresentative: $isBurstRepresentative')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
@@ -1352,3 +1645,11 @@ i0.Index get idxLocalAssetCreatedAt => i0.Index(
|
||||
'idx_local_asset_created_at',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)',
|
||||
);
|
||||
i0.Index get idxLocalAssetPriorRemoteId => i0.Index(
|
||||
'idx_local_asset_prior_remote_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_prior_remote_id ON local_asset_entity (prior_remote_id)',
|
||||
);
|
||||
i0.Index get idxLocalAssetBurstId => i0.Index(
|
||||
'idx_local_asset_burst_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_burst_id ON local_asset_entity (burst_id)',
|
||||
);
|
||||
|
||||
@@ -7,7 +7,13 @@ import 'local_album_asset.entity.dart';
|
||||
mergedAsset:
|
||||
SELECT
|
||||
rae.id as remote_id,
|
||||
(SELECT lae.id FROM local_asset_entity lae WHERE lae.checksum = rae.checksum LIMIT 1) as local_id,
|
||||
-- local_id links a remote to its on-device copy, normally by checksum. A reverted iOS
|
||||
-- edit re-encodes to fresh bytes so the checksum no longer matches, but its
|
||||
-- prior_remote_id still points at this remote, so fall back to that.
|
||||
COALESCE(
|
||||
(SELECT lae.id FROM local_asset_entity lae WHERE lae.checksum = rae.checksum LIMIT 1),
|
||||
(SELECT lae.id FROM local_asset_entity lae WHERE lae.prior_remote_id = rae.id LIMIT 1)
|
||||
) as local_id,
|
||||
rae.name,
|
||||
rae."type",
|
||||
rae.created_at as created_at,
|
||||
@@ -18,6 +24,12 @@ SELECT
|
||||
rae.is_favorite,
|
||||
rae.thumb_hash,
|
||||
rae.checksum,
|
||||
-- the linked local's current checksum (same row local_id picks), so local
|
||||
-- renders are cache-keyed by the bytes on device, not the server value.
|
||||
COALESCE(
|
||||
(SELECT lae.checksum FROM local_asset_entity lae WHERE lae.checksum = rae.checksum LIMIT 1),
|
||||
(SELECT lae.checksum FROM local_asset_entity lae WHERE lae.prior_remote_id = rae.id LIMIT 1)
|
||||
) as local_checksum,
|
||||
rae.owner_id,
|
||||
rae.live_photo_video_id,
|
||||
0 as orientation,
|
||||
@@ -41,6 +53,9 @@ WHERE
|
||||
rae.stack_id IS NULL
|
||||
OR rae.id = se.primary_asset_id
|
||||
)
|
||||
-- iOS burst: hide non-representative members only in the pre-stack-sync window;
|
||||
-- once stack_id is set the rae.id = se.primary_asset_id rule above already hides them.
|
||||
AND (rae.stack_id IS NOT NULL OR NOT EXISTS (SELECT 1 FROM local_asset_entity lae WHERE lae.checksum = rae.checksum AND lae.burst_id IS NOT NULL AND lae.is_burst_representative = 0))
|
||||
|
||||
UNION ALL
|
||||
|
||||
@@ -57,6 +72,7 @@ SELECT
|
||||
lae.is_favorite,
|
||||
NULL as thumb_hash,
|
||||
lae.checksum,
|
||||
lae.checksum as local_checksum,
|
||||
NULL as owner_id,
|
||||
NULL as live_photo_video_id,
|
||||
lae.orientation,
|
||||
@@ -83,6 +99,23 @@ AND NOT EXISTS (
|
||||
INNER JOIN local_album_entity la on laa.album_id = la.id
|
||||
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 remote row), 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.
|
||||
-- A trashed prior still hides it — trashing on the server shouldn't pop the
|
||||
-- photo back onto the local timeline; only a hard delete (row gone) does.
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM remote_asset_entity rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN :user_ids
|
||||
)
|
||||
-- iOS burst: show only the representative as a local tile; other members still back up + stack.
|
||||
-- A rep-less group (Keep Everything / re-pick) has no representative, so show every
|
||||
-- frame as an individual instead of hiding the whole group.
|
||||
AND (
|
||||
lae.burst_id IS NULL
|
||||
OR lae.is_burst_representative = 1
|
||||
OR NOT EXISTS (SELECT 1 FROM local_asset_entity r WHERE r.burst_id = lae.burst_id AND r.is_burst_representative = 1)
|
||||
)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $limit;
|
||||
|
||||
@@ -115,6 +148,8 @@ FROM
|
||||
rae.stack_id IS NULL
|
||||
OR rae.id = se.primary_asset_id
|
||||
)
|
||||
-- iOS burst: hide non-representative members only in the pre-stack-sync window (see mergedAsset)
|
||||
AND (rae.stack_id IS NOT NULL OR NOT EXISTS (SELECT 1 FROM local_asset_entity lae WHERE lae.checksum = rae.checksum AND lae.burst_id IS NOT NULL AND lae.is_burst_representative = 0))
|
||||
UNION ALL
|
||||
SELECT
|
||||
CASE
|
||||
@@ -136,6 +171,17 @@ FROM
|
||||
INNER JOIN local_album_entity la on laa.album_id = la.id
|
||||
WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded
|
||||
)
|
||||
-- iOS edit-in-progress / revert: hide a local already represented by a remote
|
||||
-- row (trashed included, same as the tile query above).
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM remote_asset_entity rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN :user_ids
|
||||
)
|
||||
-- iOS burst: count only the representative; a rep-less group counts every frame (see mergedAsset)
|
||||
AND (
|
||||
lae.burst_id IS NULL
|
||||
OR lae.is_burst_representative = 1
|
||||
OR NOT EXISTS (SELECT 1 FROM local_asset_entity r WHERE r.burst_id = lae.burst_id AND r.is_burst_representative = 1)
|
||||
)
|
||||
)
|
||||
GROUP BY bucket_date
|
||||
ORDER BY bucket_date DESC;
|
||||
|
||||
+5
-2
@@ -29,7 +29,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
|
||||
);
|
||||
$arrayStartIndex += generatedlimit.amountOfVariables;
|
||||
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, COALESCE((SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1), (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.prior_remote_id = rae.id 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, COALESCE((SELECT lae.checksum FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1), (SELECT lae.checksum FROM local_asset_entity AS lae WHERE lae.prior_remote_id = rae.id LIMIT 1)) AS local_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)AND(rae.stack_id IS NOT NULL OR NOT EXISTS (SELECT 1 AS _c0 FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum AND lae.burst_id IS NOT NULL AND lae.is_burst_representative = 0))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, lae.checksum AS local_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)) AND(lae.burst_id IS NULL OR lae.is_burst_representative = 1 OR NOT EXISTS (SELECT 1 FROM local_asset_entity AS r WHERE r.burst_id = lae.burst_id AND r.is_burst_representative = 1))ORDER BY created_at DESC ${generatedlimit.sql}',
|
||||
variables: [
|
||||
for (var $ in userIds) i0.Variable<String>($),
|
||||
...generatedlimit.introducedVariables,
|
||||
@@ -58,6 +58,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
|
||||
isFavorite: row.read<bool>('is_favorite'),
|
||||
thumbHash: row.readNullable<String>('thumb_hash'),
|
||||
checksum: row.readNullable<String>('checksum'),
|
||||
localChecksum: row.readNullable<String>('local_checksum'),
|
||||
ownerId: row.readNullable<String>('owner_id'),
|
||||
livePhotoVideoId: row.readNullable<String>('live_photo_video_id'),
|
||||
orientation: row.read<int>('orientation'),
|
||||
@@ -81,7 +82,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
|
||||
final expandeduserIds = $expandVar($arrayStartIndex, userIds.length);
|
||||
$arrayStartIndex += userIds.length;
|
||||
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)AND(rae.stack_id IS NOT NULL OR NOT EXISTS (SELECT 1 AS _c0 FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum AND lae.burst_id IS NOT NULL AND lae.is_burst_representative = 0))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)) AND(lae.burst_id IS NULL OR lae.is_burst_representative = 1 OR NOT EXISTS (SELECT 1 FROM local_asset_entity AS r WHERE r.burst_id = lae.burst_id AND r.is_burst_representative = 1))) GROUP BY bucket_date ORDER BY bucket_date DESC',
|
||||
variables: [
|
||||
i0.Variable<int>(groupBy),
|
||||
for (var $ in userIds) i0.Variable<String>($),
|
||||
@@ -132,6 +133,7 @@ class MergedAssetResult {
|
||||
final bool isFavorite;
|
||||
final String? thumbHash;
|
||||
final String? checksum;
|
||||
final String? localChecksum;
|
||||
final String? ownerId;
|
||||
final String? livePhotoVideoId;
|
||||
final int orientation;
|
||||
@@ -156,6 +158,7 @@ class MergedAssetResult {
|
||||
required this.isFavorite,
|
||||
this.thumbHash,
|
||||
this.checksum,
|
||||
this.localChecksum,
|
||||
this.ownerId,
|
||||
this.livePhotoVideoId,
|
||||
required this.orientation,
|
||||
|
||||
@@ -55,6 +55,8 @@ class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin
|
||||
}
|
||||
|
||||
extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
|
||||
// localId callers attach it via a checksum-equality join, so the local's
|
||||
// bytes are the remote's — key local renders by the same checksum.
|
||||
RemoteAsset toDto({String? localId}) => RemoteAsset(
|
||||
id: id,
|
||||
name: name,
|
||||
@@ -72,6 +74,7 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
|
||||
visibility: visibility,
|
||||
livePhotoVideoId: livePhotoVideoId,
|
||||
localId: localId,
|
||||
localChecksum: localId == null ? null : checksum,
|
||||
stackId: stackId,
|
||||
isEdited: isEdited,
|
||||
deletedAt: deletedAt,
|
||||
|
||||
@@ -34,23 +34,53 @@ class DriftBackupRepository extends DriftDatabaseRepository {
|
||||
/// - total: number of distinct assets in selected albums, excluding those that are also in excluded albums
|
||||
/// - backup: number of those assets that already exist on the server for [userId]
|
||||
/// - remainder: number of those assets that do not yet exist on the server for [userId]
|
||||
/// (includes processing)
|
||||
/// (includes processing), excluding handled iOS reverts (syncedChecksum == checksum
|
||||
/// with the prior upload still on the server — trashed counts, like the
|
||||
/// checksum arm; only a hard delete re-opens the asset)
|
||||
/// - processing: number of those assets that are still preparing/have a null checksum
|
||||
Future<({int total, int remainder, int processing})> getAllCounts(String userId) async {
|
||||
const sql = '''
|
||||
SELECT
|
||||
COUNT(*) AS total_count,
|
||||
COUNT(*) FILTER (WHERE lae.checksum IS NULL) AS processing_count,
|
||||
COUNT(*) FILTER (WHERE rae.id IS NULL) AS remainder_count
|
||||
COUNT(*) FILTER (
|
||||
WHERE rae.id IS NULL
|
||||
AND (
|
||||
lae.checksum IS NULL
|
||||
OR lae.synced_checksum IS NULL
|
||||
OR lae.synced_checksum != lae.checksum
|
||||
OR NOT EXISTS (
|
||||
SELECT 1 FROM main.remote_asset_entity pr
|
||||
WHERE pr.id = lae.prior_remote_id
|
||||
)
|
||||
)
|
||||
) AS remainder_count
|
||||
FROM local_asset_entity lae
|
||||
LEFT JOIN main.remote_asset_entity rae
|
||||
ON lae.checksum = rae.checksum AND rae.owner_id = ?1
|
||||
WHERE EXISTS (
|
||||
WHERE (
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM local_album_asset_entity laa
|
||||
INNER JOIN main.local_album_entity la on laa.album_id = la.id
|
||||
WHERE laa.asset_id = lae.id
|
||||
AND la.backup_selection = ?2
|
||||
)
|
||||
-- iOS burst: a hidden member inherits candidacy from its representative,
|
||||
-- which is the one actually in the user's selected album.
|
||||
OR (lae.is_burst_representative = 0 AND lae.burst_id IS NOT NULL AND EXISTS (
|
||||
SELECT 1 FROM local_asset_entity rep
|
||||
INNER JOIN local_album_asset_entity laa ON laa.asset_id = rep.id
|
||||
INNER JOIN main.local_album_entity la ON la.id = laa.album_id
|
||||
WHERE rep.burst_id = lae.burst_id AND rep.is_burst_representative = 1
|
||||
AND la.backup_selection = ?2
|
||||
-- exclude-wins propagates to the burst: the rep must not be excluded
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM local_album_asset_entity laa2
|
||||
INNER JOIN main.local_album_entity la2 ON la2.id = laa2.album_id
|
||||
WHERE laa2.asset_id = rep.id AND la2.backup_selection = ?3
|
||||
)
|
||||
))
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
@@ -74,6 +104,7 @@ class DriftBackupRepository extends DriftDatabaseRepository {
|
||||
.getSingle();
|
||||
|
||||
final data = row.data;
|
||||
|
||||
return (
|
||||
total: (data['total_count'] as int?) ?? 0,
|
||||
remainder: (data['remainder_count'] as int?) ?? 0,
|
||||
@@ -81,22 +112,60 @@ class DriftBackupRepository extends DriftDatabaseRepository {
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<LocalAsset>> getCandidates(String userId, {bool onlyHashed = true}) async {
|
||||
/// Backup candidates. With [burstId], scoped to the non-representative members
|
||||
/// of that burst — used to re-enqueue a burst's gated frames once its
|
||||
/// representative has uploaded, without re-walking (and re-enqueuing) assets
|
||||
/// already in flight from the main pass.
|
||||
Future<List<LocalAsset>> getCandidates(String userId, {bool onlyHashed = true, String? burstId}) async {
|
||||
final selectedAlbumIds = _db.localAlbumEntity.selectOnly(distinct: true)
|
||||
..addColumns([_db.localAlbumEntity.id])
|
||||
..where(_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected));
|
||||
|
||||
// iOS burst: a hidden member isn't a member of the user album its rep sits in
|
||||
// (Photos only adds the cover), so it inherits backup candidacy from its rep.
|
||||
// Matched with a correlated EXISTS (var-safe, mirrors getAllCounts) instead of
|
||||
// materialising the burst-id list — a large library could blow the SQLite
|
||||
// variable limit.
|
||||
final rep = _db.localAssetEntity.createAlias('rep');
|
||||
|
||||
final query = _db.localAssetEntity.select()
|
||||
..where(
|
||||
(lae) =>
|
||||
existsQuery(
|
||||
_db.localAlbumAssetEntity.selectOnly()
|
||||
..addColumns([_db.localAlbumAssetEntity.assetId])
|
||||
..where(
|
||||
_db.localAlbumAssetEntity.albumId.isInQuery(selectedAlbumIds) &
|
||||
_db.localAlbumAssetEntity.assetId.equalsExp(lae.id),
|
||||
),
|
||||
) &
|
||||
(existsQuery(
|
||||
_db.localAlbumAssetEntity.selectOnly()
|
||||
..addColumns([_db.localAlbumAssetEntity.assetId])
|
||||
..where(
|
||||
_db.localAlbumAssetEntity.albumId.isInQuery(selectedAlbumIds) &
|
||||
_db.localAlbumAssetEntity.assetId.equalsExp(lae.id),
|
||||
),
|
||||
) |
|
||||
(lae.isBurstRepresentative.equals(false) &
|
||||
lae.burstId.isNotNull() &
|
||||
existsQuery(
|
||||
rep.selectOnly()
|
||||
..addColumns([rep.id])
|
||||
..join([
|
||||
innerJoin(
|
||||
_db.localAlbumAssetEntity,
|
||||
_db.localAlbumAssetEntity.assetId.equalsExp(rep.id),
|
||||
useColumns: false,
|
||||
),
|
||||
innerJoin(
|
||||
_db.localAlbumEntity,
|
||||
_db.localAlbumEntity.id.equalsExp(_db.localAlbumAssetEntity.albumId),
|
||||
useColumns: false,
|
||||
),
|
||||
])
|
||||
..where(
|
||||
rep.burstId.equalsExp(lae.burstId) &
|
||||
rep.isBurstRepresentative.equals(true) &
|
||||
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
|
||||
// exclude-wins propagates to the burst: a member only
|
||||
// inherits candidacy if its rep is itself a candidate
|
||||
// (in a selected album AND not in an excluded one).
|
||||
rep.id.isNotInQuery(_getExcludedSubquery()),
|
||||
),
|
||||
))) &
|
||||
notExistsQuery(
|
||||
_db.remoteAssetEntity.selectOnly()
|
||||
..addColumns([_db.remoteAssetEntity.checksum])
|
||||
@@ -104,6 +173,20 @@ class DriftBackupRepository extends DriftDatabaseRepository {
|
||||
_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, so don't re-queue it as a fresh upload. Suppress while
|
||||
// the prior row exists at all — trashed stays suppressed (same
|
||||
// convention as the checksum arm above); only a hard-deleted remote
|
||||
// must become a candidate again.
|
||||
(lae.checksum.isNull() |
|
||||
lae.syncedChecksum.isNull() |
|
||||
lae.syncedChecksum.equalsExp(lae.checksum).not() |
|
||||
notExistsQuery(
|
||||
_db.remoteAssetEntity.selectOnly()
|
||||
..addColumns([_db.remoteAssetEntity.id])
|
||||
..where(_db.remoteAssetEntity.id.equalsExp(lae.priorRemoteId)),
|
||||
)) &
|
||||
lae.id.isNotInQuery(_getExcludedSubquery()),
|
||||
)
|
||||
..orderBy([(localAsset) => OrderingTerm.desc(localAsset.createdAt)]);
|
||||
@@ -112,6 +195,10 @@ class DriftBackupRepository extends DriftDatabaseRepository {
|
||||
query.where((lae) => lae.checksum.isNotNull());
|
||||
}
|
||||
|
||||
if (burstId != null) {
|
||||
query.where((lae) => lae.burstId.equals(burstId) & lae.isBurstRepresentative.equals(false));
|
||||
}
|
||||
|
||||
return query.map((localAsset) => localAsset.toDto()).get();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ class Drift extends $Drift {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 30;
|
||||
int get schemaVersion => 31;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -311,6 +311,14 @@ class Drift extends $Drift {
|
||||
from29To30: (m, v30) async {
|
||||
await m.alterTable(TableMigration(v30.settings));
|
||||
},
|
||||
from30To31: (m, v31) async {
|
||||
await m.addColumn(v31.localAssetEntity, v31.localAssetEntity.priorRemoteId);
|
||||
await m.addColumn(v31.localAssetEntity, v31.localAssetEntity.syncedChecksum);
|
||||
await m.addColumn(v31.localAssetEntity, v31.localAssetEntity.burstId);
|
||||
await m.addColumn(v31.localAssetEntity, v31.localAssetEntity.isBurstRepresentative);
|
||||
await m.createIndex(v31.idxLocalAssetPriorRemoteId);
|
||||
await m.createIndex(v31.idxLocalAssetBurstId);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -118,6 +118,8 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
i4.idxLocalAssetChecksum,
|
||||
i4.idxLocalAssetCloudId,
|
||||
i4.idxLocalAssetCreatedAt,
|
||||
i4.idxLocalAssetPriorRemoteId,
|
||||
i4.idxLocalAssetBurstId,
|
||||
i3.idxStackPrimaryAssetId,
|
||||
i2.uQRemoteAssetsOwnerChecksum,
|
||||
i2.uQRemoteAssetsOwnerLibraryChecksum,
|
||||
|
||||
@@ -15920,6 +15920,679 @@ i1.GeneratedColumn<String> _column_224(String aliasedName) =>
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NULL',
|
||||
);
|
||||
|
||||
final class Schema31 extends i0.VersionedSchema {
|
||||
Schema31({required super.database}) : super(version: 31);
|
||||
@override
|
||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||
userEntity,
|
||||
remoteAssetEntity,
|
||||
stackEntity,
|
||||
localAssetEntity,
|
||||
remoteAlbumEntity,
|
||||
localAlbumEntity,
|
||||
localAlbumAssetEntity,
|
||||
idxLocalAlbumAssetAlbumAsset,
|
||||
idxLocalAssetChecksum,
|
||||
idxLocalAssetCloudId,
|
||||
idxLocalAssetCreatedAt,
|
||||
idxLocalAssetPriorRemoteId,
|
||||
idxLocalAssetBurstId,
|
||||
idxStackPrimaryAssetId,
|
||||
uQRemoteAssetsOwnerChecksum,
|
||||
uQRemoteAssetsOwnerLibraryChecksum,
|
||||
idxRemoteAssetChecksum,
|
||||
idxRemoteAssetStackId,
|
||||
idxRemoteAssetOwnerVisibilityDeletedCreated,
|
||||
authUserEntity,
|
||||
userMetadataEntity,
|
||||
partnerEntity,
|
||||
remoteExifEntity,
|
||||
remoteAlbumAssetEntity,
|
||||
remoteAlbumUserEntity,
|
||||
remoteAssetCloudIdEntity,
|
||||
memoryEntity,
|
||||
memoryAssetEntity,
|
||||
personEntity,
|
||||
assetFaceEntity,
|
||||
storeEntity,
|
||||
trashedLocalAssetEntity,
|
||||
assetEditEntity,
|
||||
settings,
|
||||
assetOcrEntity,
|
||||
idxPartnerSharedWithId,
|
||||
idxLatLng,
|
||||
idxRemoteExifCity,
|
||||
idxRemoteAlbumAssetAlbumAsset,
|
||||
idxRemoteAssetCloudId,
|
||||
idxPersonOwnerId,
|
||||
idxAssetFacePersonId,
|
||||
idxAssetFaceAssetId,
|
||||
idxAssetFaceVisiblePerson,
|
||||
idxTrashedLocalAssetChecksum,
|
||||
idxTrashedLocalAssetAlbum,
|
||||
idxAssetEditAssetId,
|
||||
idxAssetOcrAssetId,
|
||||
];
|
||||
late final Shape33 userEntity = Shape33(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_109,
|
||||
_column_110,
|
||||
_column_111,
|
||||
_column_112,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape50 remoteAssetEntity = Shape50(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_108,
|
||||
_column_113,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_116,
|
||||
_column_117,
|
||||
_column_118,
|
||||
_column_107,
|
||||
_column_119,
|
||||
_column_120,
|
||||
_column_121,
|
||||
_column_122,
|
||||
_column_123,
|
||||
_column_124,
|
||||
_column_212,
|
||||
_column_125,
|
||||
_column_126,
|
||||
_column_127,
|
||||
_column_128,
|
||||
_column_129,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape35 stackEntity = Shape35(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'stack_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_121,
|
||||
_column_130,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape52 localAssetEntity = Shape52(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_108,
|
||||
_column_113,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_116,
|
||||
_column_117,
|
||||
_column_118,
|
||||
_column_107,
|
||||
_column_131,
|
||||
_column_120,
|
||||
_column_132,
|
||||
_column_133,
|
||||
_column_134,
|
||||
_column_135,
|
||||
_column_136,
|
||||
_column_137,
|
||||
_column_225,
|
||||
_column_226,
|
||||
_column_227,
|
||||
_column_228,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape48 remoteAlbumEntity = Shape48(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_138,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_139,
|
||||
_column_140,
|
||||
_column_141,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape38 localAlbumEntity = Shape38(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_115,
|
||||
_column_142,
|
||||
_column_143,
|
||||
_column_144,
|
||||
_column_145,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape39 localAlbumAssetEntity = Shape39(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_146, _column_147, _column_145],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
|
||||
'idx_local_album_asset_album_asset',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
|
||||
);
|
||||
final i1.Index idxLocalAssetChecksum = i1.Index(
|
||||
'idx_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxLocalAssetCloudId = i1.Index(
|
||||
'idx_local_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
|
||||
);
|
||||
final i1.Index idxLocalAssetCreatedAt = i1.Index(
|
||||
'idx_local_asset_created_at',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)',
|
||||
);
|
||||
final i1.Index idxLocalAssetPriorRemoteId = i1.Index(
|
||||
'idx_local_asset_prior_remote_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_prior_remote_id ON local_asset_entity (prior_remote_id)',
|
||||
);
|
||||
final i1.Index idxLocalAssetBurstId = i1.Index(
|
||||
'idx_local_asset_burst_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_burst_id ON local_asset_entity (burst_id)',
|
||||
);
|
||||
final i1.Index idxStackPrimaryAssetId = i1.Index(
|
||||
'idx_stack_primary_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_library_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetChecksum = i1.Index(
|
||||
'idx_remote_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetStackId = i1.Index(
|
||||
'idx_remote_asset_stack_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index(
|
||||
'idx_remote_asset_owner_visibility_deleted_created',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
|
||||
);
|
||||
late final Shape40 authUserEntity = Shape40(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'auth_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_109,
|
||||
_column_148,
|
||||
_column_110,
|
||||
_column_111,
|
||||
_column_149,
|
||||
_column_150,
|
||||
_column_151,
|
||||
_column_152,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape4 userMetadataEntity = Shape4(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_metadata_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
|
||||
columns: [_column_153, _column_154, _column_155],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape41 partnerEntity = Shape41(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'partner_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
|
||||
columns: [_column_156, _column_157, _column_158],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape42 remoteExifEntity = Shape42(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_exif_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_159,
|
||||
_column_160,
|
||||
_column_161,
|
||||
_column_162,
|
||||
_column_163,
|
||||
_column_164,
|
||||
_column_117,
|
||||
_column_116,
|
||||
_column_165,
|
||||
_column_166,
|
||||
_column_167,
|
||||
_column_168,
|
||||
_column_135,
|
||||
_column_136,
|
||||
_column_169,
|
||||
_column_170,
|
||||
_column_171,
|
||||
_column_172,
|
||||
_column_173,
|
||||
_column_174,
|
||||
_column_175,
|
||||
_column_176,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape7 remoteAlbumAssetEntity = Shape7(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_159, _column_177],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape10 remoteAlbumUserEntity = Shape10(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
|
||||
columns: [_column_177, _column_153, _column_178],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape43 remoteAssetCloudIdEntity = Shape43(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_cloud_id_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_159,
|
||||
_column_179,
|
||||
_column_180,
|
||||
_column_134,
|
||||
_column_135,
|
||||
_column_136,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape44 memoryEntity = Shape44(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_124,
|
||||
_column_121,
|
||||
_column_113,
|
||||
_column_181,
|
||||
_column_182,
|
||||
_column_183,
|
||||
_column_184,
|
||||
_column_185,
|
||||
_column_186,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape12 memoryAssetEntity = Shape12(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
|
||||
columns: [_column_159, _column_187],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape45 personEntity = Shape45(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'person_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_121,
|
||||
_column_108,
|
||||
_column_188,
|
||||
_column_189,
|
||||
_column_190,
|
||||
_column_191,
|
||||
_column_192,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape46 assetFaceEntity = Shape46(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_face_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_159,
|
||||
_column_193,
|
||||
_column_194,
|
||||
_column_195,
|
||||
_column_196,
|
||||
_column_197,
|
||||
_column_198,
|
||||
_column_199,
|
||||
_column_200,
|
||||
_column_201,
|
||||
_column_124,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape18 storeEntity = Shape18(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'store_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [_column_202, _column_203, _column_204],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape47 trashedLocalAssetEntity = Shape47(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'trashed_local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id, album_id)'],
|
||||
columns: [
|
||||
_column_108,
|
||||
_column_113,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_116,
|
||||
_column_117,
|
||||
_column_118,
|
||||
_column_107,
|
||||
_column_205,
|
||||
_column_131,
|
||||
_column_120,
|
||||
_column_132,
|
||||
_column_206,
|
||||
_column_137,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape32 assetEditEntity = Shape32(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_edit_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_159,
|
||||
_column_207,
|
||||
_column_208,
|
||||
_column_209,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape49 settings = Shape49(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'settings',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY("key")'],
|
||||
columns: [_column_210, _column_224, _column_115],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape51 assetOcrEntity = Shape51(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_ocr_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_159,
|
||||
_column_213,
|
||||
_column_214,
|
||||
_column_215,
|
||||
_column_216,
|
||||
_column_217,
|
||||
_column_218,
|
||||
_column_219,
|
||||
_column_220,
|
||||
_column_221,
|
||||
_column_222,
|
||||
_column_223,
|
||||
_column_201,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxPartnerSharedWithId = i1.Index(
|
||||
'idx_partner_shared_with_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
|
||||
);
|
||||
final i1.Index idxLatLng = i1.Index(
|
||||
'idx_lat_lng',
|
||||
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
|
||||
);
|
||||
final i1.Index idxRemoteExifCity = i1.Index(
|
||||
'idx_remote_exif_city',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL',
|
||||
);
|
||||
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
|
||||
'idx_remote_album_asset_album_asset',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetCloudId = i1.Index(
|
||||
'idx_remote_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
|
||||
);
|
||||
final i1.Index idxPersonOwnerId = i1.Index(
|
||||
'idx_person_owner_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
|
||||
);
|
||||
final i1.Index idxAssetFacePersonId = i1.Index(
|
||||
'idx_asset_face_person_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
|
||||
);
|
||||
final i1.Index idxAssetFaceAssetId = i1.Index(
|
||||
'idx_asset_face_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
|
||||
);
|
||||
final i1.Index idxAssetFaceVisiblePerson = i1.Index(
|
||||
'idx_asset_face_visible_person',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL',
|
||||
);
|
||||
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
|
||||
'idx_trashed_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
|
||||
'idx_trashed_local_asset_album',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
|
||||
);
|
||||
final i1.Index idxAssetEditAssetId = i1.Index(
|
||||
'idx_asset_edit_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
|
||||
);
|
||||
final i1.Index idxAssetOcrAssetId = i1.Index(
|
||||
'idx_asset_ocr_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_ocr_asset_id ON asset_ocr_entity (asset_id)',
|
||||
);
|
||||
}
|
||||
|
||||
class Shape52 extends i0.VersionedTable {
|
||||
Shape52({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get name =>
|
||||
columnsByName['name']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get type =>
|
||||
columnsByName['type']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get createdAt =>
|
||||
columnsByName['created_at']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get updatedAt =>
|
||||
columnsByName['updated_at']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get width =>
|
||||
columnsByName['width']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get height =>
|
||||
columnsByName['height']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get durationMs =>
|
||||
columnsByName['duration_ms']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get checksum =>
|
||||
columnsByName['checksum']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get isFavorite =>
|
||||
columnsByName['is_favorite']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get orientation =>
|
||||
columnsByName['orientation']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get iCloudId =>
|
||||
columnsByName['i_cloud_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get adjustmentTime =>
|
||||
columnsByName['adjustment_time']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<double> get latitude =>
|
||||
columnsByName['latitude']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<double> get longitude =>
|
||||
columnsByName['longitude']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<int> get playbackStyle =>
|
||||
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get priorRemoteId =>
|
||||
columnsByName['prior_remote_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get syncedChecksum =>
|
||||
columnsByName['synced_checksum']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get burstId =>
|
||||
columnsByName['burst_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get isBurstRepresentative =>
|
||||
columnsByName['is_burst_representative']! as i1.GeneratedColumn<int>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<String> _column_225(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'prior_remote_id',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NULL',
|
||||
);
|
||||
i1.GeneratedColumn<String> _column_226(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'synced_checksum',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NULL',
|
||||
);
|
||||
i1.GeneratedColumn<String> _column_227(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'burst_id',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NULL',
|
||||
);
|
||||
i1.GeneratedColumn<int> _column_228(String aliasedName) =>
|
||||
i1.GeneratedColumn<int>(
|
||||
'is_burst_representative',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.int,
|
||||
$customConstraints:
|
||||
'NOT NULL DEFAULT 0 CHECK (is_burst_representative IN (0, 1))',
|
||||
defaultValue: const i1.CustomExpression('0'),
|
||||
);
|
||||
i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||
@@ -15950,6 +16623,7 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
|
||||
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
|
||||
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
|
||||
required Future<void> Function(i1.Migrator m, Schema31 schema) from30To31,
|
||||
}) {
|
||||
return (currentVersion, database) async {
|
||||
switch (currentVersion) {
|
||||
@@ -16098,6 +16772,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from29To30(migrator, schema);
|
||||
return 30;
|
||||
case 30:
|
||||
final schema = Schema31(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from30To31(migrator, schema);
|
||||
return 31;
|
||||
default:
|
||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||
}
|
||||
@@ -16134,6 +16813,7 @@ i1.OnUpgrade stepByStep({
|
||||
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
|
||||
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
|
||||
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
|
||||
required Future<void> Function(i1.Migrator m, Schema31 schema) from30To31,
|
||||
}) => i0.VersionedSchema.stepByStepHelper(
|
||||
step: migrationSteps(
|
||||
from1To2: from1To2,
|
||||
@@ -16165,5 +16845,6 @@ i1.OnUpgrade stepByStep({
|
||||
from27To28: from27To28,
|
||||
from28To29: from28To29,
|
||||
from29To30: from29To30,
|
||||
from30To31: from30To31,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -235,15 +235,47 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<LocalAsset>> getAssetsToHash(String albumId) {
|
||||
final query =
|
||||
_db.localAlbumAssetEntity.select().join([
|
||||
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
|
||||
])
|
||||
..where(_db.localAlbumAssetEntity.albumId.equals(albumId) & _db.localAssetEntity.checksum.isNull())
|
||||
..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)]);
|
||||
Future<List<LocalAsset>> getAssetsToHash(String albumId) async {
|
||||
// iOS burst: hidden members live in the smart album, not a user album, so a
|
||||
// burst added to a backup album would never hash its other frames. Let a
|
||||
// member inherit hashing from its representative via a correlated EXISTS
|
||||
// (var-safe — a large library could blow the SQLite variable limit otherwise).
|
||||
final rep = _db.localAssetEntity.createAlias('rep');
|
||||
|
||||
return query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get();
|
||||
final query = _db.localAssetEntity.select()
|
||||
..where(
|
||||
(lae) =>
|
||||
lae.checksum.isNull() &
|
||||
(existsQuery(
|
||||
_db.localAlbumAssetEntity.selectOnly()
|
||||
..addColumns([_db.localAlbumAssetEntity.assetId])
|
||||
..where(
|
||||
_db.localAlbumAssetEntity.albumId.equals(albumId) &
|
||||
_db.localAlbumAssetEntity.assetId.equalsExp(lae.id),
|
||||
),
|
||||
) |
|
||||
(lae.isBurstRepresentative.equals(false) &
|
||||
lae.burstId.isNotNull() &
|
||||
existsQuery(
|
||||
rep.selectOnly()
|
||||
..addColumns([rep.id])
|
||||
..join([
|
||||
innerJoin(
|
||||
_db.localAlbumAssetEntity,
|
||||
_db.localAlbumAssetEntity.assetId.equalsExp(rep.id),
|
||||
useColumns: false,
|
||||
),
|
||||
])
|
||||
..where(
|
||||
rep.burstId.equalsExp(lae.burstId) &
|
||||
rep.isBurstRepresentative.equals(true) &
|
||||
_db.localAlbumAssetEntity.albumId.equals(albumId),
|
||||
),
|
||||
))),
|
||||
)
|
||||
..orderBy([(lae) => OrderingTerm.desc(lae.createdAt)]);
|
||||
|
||||
return query.map((row) => row.toDto()).get();
|
||||
}
|
||||
|
||||
Future<void> updateCloudMapping(Map<String, String> cloudMapping) {
|
||||
@@ -305,6 +337,10 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
||||
latitude: Value(asset.latitude),
|
||||
longitude: Value(asset.longitude),
|
||||
adjustmentTime: Value(asset.adjustmentTime),
|
||||
// Re-synced on every delta (DoUpdate carries the same companion) so a
|
||||
// Photos re-pick that moves the representative flag is reflected.
|
||||
burstId: Value(asset.burstId),
|
||||
isBurstRepresentative: Value(asset.isBurstRepresentative),
|
||||
);
|
||||
batch.insert<$LocalAssetEntityTable, LocalAssetEntityData>(
|
||||
_db.localAssetEntity,
|
||||
|
||||
@@ -64,6 +64,84 @@ 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)),
|
||||
);
|
||||
}
|
||||
|
||||
/// The remote id to stack a burst member under: the prior_remote_id of any
|
||||
/// already-uploaded member of the same burst (null until the first one lands).
|
||||
/// The representative uploads first (non-reps gate on this), so the first hit
|
||||
/// is the rep and `keepPrimary` pins it as the stack primary. Deliberately NOT
|
||||
/// filtered to the representative: iOS can move the rep flag (a Photos re-pick),
|
||||
/// and any in-stack member resolves to the same stack via linkAsset — so
|
||||
/// returning whichever member uploaded keeps every later frame in one stack
|
||||
/// instead of spawning a second when the cover moves. Stable order so repeated
|
||||
/// lookups pick the same anchor.
|
||||
Future<String?> getBurstParentRemoteId(String burstId, {String? ownerId}) async {
|
||||
// Prefer the remote id stamped by a frame this device already uploaded.
|
||||
final row =
|
||||
await (_db.localAssetEntity.selectOnly()
|
||||
..addColumns([_db.localAssetEntity.priorRemoteId])
|
||||
..where(_db.localAssetEntity.burstId.equals(burstId) & _db.localAssetEntity.priorRemoteId.isNotNull())
|
||||
..orderBy([OrderingTerm.asc(_db.localAssetEntity.createdAt), OrderingTerm.asc(_db.localAssetEntity.id)])
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
final priorRemoteId = row?.read(_db.localAssetEntity.priorRemoteId);
|
||||
if (priorRemoteId != null) {
|
||||
return priorRemoteId;
|
||||
}
|
||||
if (ownerId == null) {
|
||||
return null;
|
||||
}
|
||||
// Pre-existing burst: the representative was backed up before this feature, so
|
||||
// no local frame ever stamped a prior. Anchor onto the rep's already-synced
|
||||
// remote row (matched by checksum) so the hidden members can still stack.
|
||||
final rep =
|
||||
await (_db.localAssetEntity.selectOnly()
|
||||
..addColumns([_db.remoteAssetEntity.id])
|
||||
..join([
|
||||
innerJoin(
|
||||
_db.remoteAssetEntity,
|
||||
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum) &
|
||||
_db.remoteAssetEntity.ownerId.equals(ownerId),
|
||||
useColumns: false,
|
||||
),
|
||||
])
|
||||
..where(
|
||||
_db.localAssetEntity.burstId.equals(burstId) & _db.localAssetEntity.isBurstRepresentative.equals(true),
|
||||
)
|
||||
..orderBy([OrderingTerm.asc(_db.localAssetEntity.createdAt), OrderingTerm.asc(_db.localAssetEntity.id)])
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
return rep?.read(_db.remoteAssetEntity.id);
|
||||
}
|
||||
|
||||
/// Whether the burst group still has a representative frame. A group can end up
|
||||
/// rep-less (every frame is_burst_representative=0) after a Photos "Keep
|
||||
/// Everything" / re-pick — its members can never anchor a stack, so callers
|
||||
/// upload them standalone instead of gating forever.
|
||||
Future<bool> burstHasRepresentative(String burstId) async {
|
||||
final row =
|
||||
await (_db.localAssetEntity.selectOnly()
|
||||
..addColumns([_db.localAssetEntity.id])
|
||||
..where(
|
||||
_db.localAssetEntity.burstId.equals(burstId) & _db.localAssetEntity.isBurstRepresentative.equals(true),
|
||||
)
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
return row != null;
|
||||
}
|
||||
|
||||
/// Drops the edit-stacking stamps so the next backup cycle re-resolves the
|
||||
/// asset from scratch (used when the server says the stamped prior is gone).
|
||||
Future<void> clearSyncStamps(String localId) {
|
||||
return (_db.localAssetEntity.update()..where((e) => e.id.equals(localId))).write(
|
||||
const LocalAssetEntityCompanion(priorRemoteId: Value(null), syncedChecksum: Value(null)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> delete(List<String> ids) {
|
||||
if (ids.isEmpty) {
|
||||
return Future.value();
|
||||
|
||||
@@ -46,7 +46,9 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||
|
||||
return query.map((row) {
|
||||
final asset = row.readTable(_db.remoteAssetEntity).toDto();
|
||||
return asset.copyWith(localId: row.read(_db.localAssetEntity.id));
|
||||
final localId = row.read(_db.localAssetEntity.id);
|
||||
// checksum-equality join: the local's bytes are the remote's
|
||||
return asset.copyWith(localId: localId, localChecksum: localId == null ? null : asset.checksum);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,156 @@
|
||||
import 'package:drift/drift.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/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,
|
||||
});
|
||||
}
|
||||
|
||||
enum PriorState { live, trashed, missing }
|
||||
|
||||
class DriftStackRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
const DriftStackRepository(this._db) : super(_db);
|
||||
|
||||
Future<List<Stack>> getAll(String userId) {
|
||||
final query = _db.stackEntity.select()..where((e) => e.ownerId.equals(userId));
|
||||
// Find stacks whose primary should flip back after a revert: a local that was
|
||||
// uploaded as an edit (prior in the stack) now hashes to a DIFFERENT member
|
||||
// that isn't the primary. Two discriminators keep this from fighting stacks
|
||||
// the user arranged by hand: the matched member must not be the local's own
|
||||
// prior (a true revert has prior = the edit, member = the base), and the
|
||||
// local must be unreconciled (synced_checksum != checksum — the flip below
|
||||
// writes synced = checksum, which is what makes this self-limiting). Driven
|
||||
// from stack_entity so the work scales with the number of stacks (few), and
|
||||
// runs every hash cycle so a flip that failed offline gets retried.
|
||||
Future<List<StackReconcileTarget>> findRevertReconcileTargets() async {
|
||||
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 stack_entity s
|
||||
INNER JOIN remote_asset_entity member
|
||||
ON member.stack_id = s.id
|
||||
AND member.deleted_at IS NULL
|
||||
INNER JOIN local_asset_entity local
|
||||
ON local.checksum = member.checksum
|
||||
AND local.prior_remote_id IS NOT NULL
|
||||
AND local.prior_remote_id != member.id
|
||||
AND local.synced_checksum IS NOT local.checksum
|
||||
INNER JOIN remote_asset_entity prior
|
||||
ON prior.id = local.prior_remote_id
|
||||
AND prior.stack_id = s.id
|
||||
AND prior.deleted_at IS NULL
|
||||
WHERE s.primary_asset_id != member.id
|
||||
''',
|
||||
readsFrom: {_db.localAssetEntity, _db.remoteAssetEntity, _db.stackEntity},
|
||||
)
|
||||
.get();
|
||||
|
||||
return query.map((stack) {
|
||||
return stack.toDto();
|
||||
}).get();
|
||||
}
|
||||
}
|
||||
|
||||
extension on StackEntityData {
|
||||
Stack toDto() {
|
||||
return Stack(id: id, createdAt: createdAt, updatedAt: updatedAt, ownerId: ownerId, primaryAssetId: primaryAssetId);
|
||||
return rows
|
||||
.map(
|
||||
(row) => 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'),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// A trashed or locked-folder (visibility = 3) remote can't be stacked onto,
|
||||
// so it reads as trashed; anything else is live.
|
||||
PriorState _stateFromBlocked(bool blocked) => blocked ? PriorState.trashed : PriorState.live;
|
||||
|
||||
// What the synced remote table knows about a stamped prior. missing is
|
||||
// ambiguous: either just uploaded and not synced back yet, or hard-deleted on
|
||||
// the server — the caller tells them apart via syncedChecksum (null = a chain
|
||||
// is still mid-flight, so the row simply hasn't synced yet). A locked-folder
|
||||
// row counts as trashed: the server refuses to stack onto it (and with a
|
||||
// message the dead-parent belt doesn't match), so defer until it's unlocked.
|
||||
Future<PriorState> priorState(String remoteId) async {
|
||||
final row = await _db
|
||||
.customSelect(
|
||||
// 3 = locked
|
||||
'SELECT (deleted_at IS NOT NULL OR visibility = 3) AS blocked FROM remote_asset_entity WHERE id = ? LIMIT 1',
|
||||
variables: [Variable<String>(remoteId)],
|
||||
readsFrom: {_db.remoteAssetEntity},
|
||||
)
|
||||
.getSingleOrNull();
|
||||
if (row == null) {
|
||||
return PriorState.missing;
|
||||
}
|
||||
return _stateFromBlocked(row.read<bool>('blocked'));
|
||||
}
|
||||
|
||||
// The synced remote owned by [ownerId] with these exact bytes, if any. The
|
||||
// server keys assets by (owner, checksum), so at most one row matches.
|
||||
// Locked rows count as trashed here too, same reasoning as [priorState].
|
||||
Future<({PriorState state, String? remoteId})> remoteByChecksum(String checksum, String ownerId) async {
|
||||
final row = await _db
|
||||
.customSelect(
|
||||
// 3 = locked
|
||||
'SELECT id, (deleted_at IS NOT NULL OR visibility = 3) AS blocked FROM remote_asset_entity WHERE checksum = ? AND owner_id = ? LIMIT 1',
|
||||
variables: [Variable<String>(checksum), Variable<String>(ownerId)],
|
||||
readsFrom: {_db.remoteAssetEntity},
|
||||
)
|
||||
.getSingleOrNull();
|
||||
if (row == null) {
|
||||
return (state: PriorState.missing, remoteId: null);
|
||||
}
|
||||
return (state: _stateFromBlocked(row.read<bool>('blocked')), remoteId: row.read<String>('id'));
|
||||
}
|
||||
|
||||
// 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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
class StorageRepository {
|
||||
@@ -150,4 +151,34 @@ class StorageRepository {
|
||||
log.warning("Error deleting temporary directory", error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
/// Base originals for the edit chain live under Library/Caches (immich_base),
|
||||
/// not tmp, so [clearCache] can't wipe a chain still in flight across
|
||||
/// launches. Sweeps only files older than a day: live chains and concurrent
|
||||
/// foreground pair uploads keep their temps; orphans from dead chains go.
|
||||
Future<void> clearEditBaseCache() async {
|
||||
if (!CurrentPlatform.isIOS) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final cache = await getApplicationCacheDirectory();
|
||||
final dir = Directory('${cache.path}/immich_base');
|
||||
if (!await dir.exists()) {
|
||||
return;
|
||||
}
|
||||
final cutoff = DateTime.now().subtract(const Duration(days: 1));
|
||||
await for (final entry in dir.list()) {
|
||||
try {
|
||||
final stat = await entry.stat();
|
||||
if (stat.modified.isBefore(cutoff)) {
|
||||
await entry.delete(recursive: true);
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
log.warning("Error sweeping ${entry.path}", error, stackTrace);
|
||||
}
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
log.warning("Error sweeping edit base cache", error, stackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dar
|
||||
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart';
|
||||
@@ -71,6 +72,13 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
await _db.remoteAssetCloudIdEntity.deleteAll();
|
||||
await _db.assetEditEntity.deleteAll();
|
||||
await _db.assetOcrEntity.deleteAll();
|
||||
// The edit-stacking stamps point at remote rows wiped above; left in
|
||||
// place they'd make the next backup (possibly a different account or
|
||||
// server) stack onto ids that no longer exist. Only stamped rows need
|
||||
// clearing, so skip the full-table rewrite when none are set.
|
||||
await (_db.localAssetEntity.update()
|
||||
..where((e) => e.priorRemoteId.isNotNull() | e.syncedChecksum.isNotNull()))
|
||||
.write(const LocalAssetEntityCompanion(priorRemoteId: Value(null), syncedChecksum: Value(null)));
|
||||
});
|
||||
} finally {
|
||||
// re-enable FK even if the transaction throws, otherwise the connection
|
||||
@@ -195,7 +203,27 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateAssetsV1(Iterable<SyncAssetV1> data, {String debugLabel = 'user'}) async {
|
||||
// websocket events are a point-in-time snapshot, so on the fast path don't overwrite
|
||||
// link state the checkpoint sync owns (a motion video uploads visible then gets hidden).
|
||||
RemoteAssetEntityCompanion _conflictUpdate(RemoteAssetEntityCompanion companion, bool fromWebsocket) {
|
||||
if (!fromWebsocket) {
|
||||
return companion;
|
||||
}
|
||||
// deletedAt is checkpoint-owned too: a debounced upload-ready snapshot always
|
||||
// carries null and must not resurrect an asset trashed in the meantime.
|
||||
return companion.copyWith(
|
||||
visibility: const Value.absent(),
|
||||
livePhotoVideoId: const Value.absent(),
|
||||
stackId: const Value.absent(),
|
||||
deletedAt: const Value.absent(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateAssetsV1(
|
||||
Iterable<SyncAssetV1> data, {
|
||||
String debugLabel = 'user',
|
||||
bool fromWebsocket = false,
|
||||
}) async {
|
||||
try {
|
||||
await _db.batch((batch) {
|
||||
for (final asset in data) {
|
||||
@@ -224,7 +252,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
batch.insert(
|
||||
_db.remoteAssetEntity,
|
||||
companion.copyWith(id: Value(asset.id)),
|
||||
onConflict: DoUpdate((_) => companion),
|
||||
onConflict: DoUpdate((_) => _conflictUpdate(companion, fromWebsocket)),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -234,7 +262,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateAssetsV2(Iterable<SyncAssetV2> data, {String debugLabel = 'user'}) async {
|
||||
Future<void> updateAssetsV2(
|
||||
Iterable<SyncAssetV2> data, {
|
||||
String debugLabel = 'user',
|
||||
bool fromWebsocket = false,
|
||||
}) async {
|
||||
try {
|
||||
await _db.batch((batch) {
|
||||
for (final asset in data) {
|
||||
@@ -263,7 +295,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
batch.insert(
|
||||
_db.remoteAssetEntity,
|
||||
companion.copyWith(id: Value(asset.id)),
|
||||
onConflict: DoUpdate((_) => companion),
|
||||
onConflict: DoUpdate((_) => _conflictUpdate(companion, fromWebsocket)),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -88,6 +88,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
livePhotoVideoId: row.livePhotoVideoId,
|
||||
stackId: row.stackId,
|
||||
isEdited: row.isEdited,
|
||||
localChecksum: row.localChecksum,
|
||||
)
|
||||
: LocalAsset(
|
||||
id: row.localId!,
|
||||
|
||||
@@ -263,7 +263,6 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
|
||||
child: MaterialApp.router(
|
||||
title: 'Immich',
|
||||
debugShowCheckedModeBanner: true,
|
||||
scaffoldMessengerKey: scaffoldMessengerKey,
|
||||
localizationsDelegates: context.localizationDelegates,
|
||||
supportedLocales: context.supportedLocales,
|
||||
locale: context.locale,
|
||||
|
||||
+197
-11
@@ -88,6 +88,8 @@ int _deepHash(Object? value) {
|
||||
|
||||
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
|
||||
|
||||
enum EditState { notEdited, edited, unknown }
|
||||
|
||||
class PlatformAsset {
|
||||
PlatformAsset({
|
||||
required this.id,
|
||||
@@ -104,6 +106,8 @@ class PlatformAsset {
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
required this.playbackStyle,
|
||||
this.burstId,
|
||||
required this.isBurstRepresentative,
|
||||
});
|
||||
|
||||
String id;
|
||||
@@ -134,6 +138,10 @@ class PlatformAsset {
|
||||
|
||||
PlatformAssetPlaybackStyle playbackStyle;
|
||||
|
||||
String? burstId;
|
||||
|
||||
bool isBurstRepresentative;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[
|
||||
id,
|
||||
@@ -150,6 +158,8 @@ class PlatformAsset {
|
||||
latitude,
|
||||
longitude,
|
||||
playbackStyle,
|
||||
burstId,
|
||||
isBurstRepresentative,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -174,6 +184,8 @@ class PlatformAsset {
|
||||
latitude: result[11] as double?,
|
||||
longitude: result[12] as double?,
|
||||
playbackStyle: result[13]! as PlatformAssetPlaybackStyle,
|
||||
burstId: result[14] as String?,
|
||||
isBurstRepresentative: result[15]! as bool,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -199,7 +211,9 @@ class PlatformAsset {
|
||||
_deepEquals(adjustmentTime, other.adjustmentTime) &&
|
||||
_deepEquals(latitude, other.latitude) &&
|
||||
_deepEquals(longitude, other.longitude) &&
|
||||
_deepEquals(playbackStyle, other.playbackStyle);
|
||||
_deepEquals(playbackStyle, other.playbackStyle) &&
|
||||
_deepEquals(burstId, other.burstId) &&
|
||||
_deepEquals(isBurstRepresentative, other.isBurstRepresentative);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -395,6 +409,80 @@ class CloudIdResult {
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
}
|
||||
|
||||
class BaseResource {
|
||||
BaseResource({required this.path, required this.sha1});
|
||||
|
||||
String path;
|
||||
|
||||
String sha1;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[path, sha1];
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
}
|
||||
|
||||
class BaseLivePhoto {
|
||||
BaseLivePhoto({required this.still, this.video});
|
||||
|
||||
BaseResource still;
|
||||
|
||||
BaseResource? video;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[still, video];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
|
||||
static BaseLivePhoto decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return BaseLivePhoto(still: result[0]! as BaseResource, video: result[1] as BaseResource?);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
bool operator ==(Object other) {
|
||||
if (other is! BaseLivePhoto || other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(still, other.still) && _deepEquals(video, other.video);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
}
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
@@ -405,21 +493,30 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||
} else if (value is PlatformAssetPlaybackStyle) {
|
||||
buffer.putUint8(129);
|
||||
writeValue(buffer, value.index);
|
||||
} else if (value is PlatformAsset) {
|
||||
} else if (value is EditState) {
|
||||
buffer.putUint8(130);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is PlatformAlbum) {
|
||||
writeValue(buffer, value.index);
|
||||
} else if (value is PlatformAsset) {
|
||||
buffer.putUint8(131);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is SyncDelta) {
|
||||
} else if (value is PlatformAlbum) {
|
||||
buffer.putUint8(132);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is HashResult) {
|
||||
} else if (value is SyncDelta) {
|
||||
buffer.putUint8(133);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is CloudIdResult) {
|
||||
} else if (value is HashResult) {
|
||||
buffer.putUint8(134);
|
||||
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 if (value is BaseLivePhoto) {
|
||||
buffer.putUint8(137);
|
||||
writeValue(buffer, value.encode());
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
}
|
||||
@@ -432,15 +529,22 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||
final value = readValue(buffer) as int?;
|
||||
return value == null ? null : PlatformAssetPlaybackStyle.values[value];
|
||||
case 130:
|
||||
return PlatformAsset.decode(readValue(buffer)!);
|
||||
final value = readValue(buffer) as int?;
|
||||
return value == null ? null : EditState.values[value];
|
||||
case 131:
|
||||
return PlatformAlbum.decode(readValue(buffer)!);
|
||||
return PlatformAsset.decode(readValue(buffer)!);
|
||||
case 132:
|
||||
return SyncDelta.decode(readValue(buffer)!);
|
||||
return PlatformAlbum.decode(readValue(buffer)!);
|
||||
case 133:
|
||||
return HashResult.decode(readValue(buffer)!);
|
||||
return SyncDelta.decode(readValue(buffer)!);
|
||||
case 134:
|
||||
return HashResult.decode(readValue(buffer)!);
|
||||
case 135:
|
||||
return CloudIdResult.decode(readValue(buffer)!);
|
||||
case 136:
|
||||
return BaseResource.decode(readValue(buffer)!);
|
||||
case 137:
|
||||
return BaseLivePhoto.decode(readValue(buffer)!);
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
}
|
||||
@@ -705,4 +809,86 @@ class NativeSyncApi {
|
||||
);
|
||||
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?;
|
||||
}
|
||||
|
||||
/// Streams the bytes immich treats as the asset's canonical content — the same
|
||||
/// resource [hashAssets] hashes (`PHAsset.getResource()`, the `.isCurrent`
|
||||
/// rendition). Used to upload iOS burst members: they're invisible to
|
||||
/// photo_manager, so this is the only way to read their file, and streaming
|
||||
/// the same resource the hash measured keeps the server checksum aligned with
|
||||
/// the local one (else the asset shows cloud-only). iOS-only; android returns null.
|
||||
Future<BaseResource?> getCurrentResource(String assetId, {bool allowNetworkAccess = false}) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCurrentResource$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;
|
||||
}
|
||||
|
||||
Future<BaseLivePhoto?> getBaseLivePhoto(String assetId, {bool allowNetworkAccess = false}) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseLivePhoto$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 BaseLivePhoto?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +159,13 @@ ImageProvider getFullImageProvider(
|
||||
provider = FileImage(File(localFilePath));
|
||||
} else if (_shouldUseLocalAsset(asset)) {
|
||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
|
||||
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage);
|
||||
provider = LocalFullImageProvider(
|
||||
id: id,
|
||||
size: size,
|
||||
assetType: asset.type,
|
||||
isAnimated: asset.isAnimatedImage,
|
||||
checksum: _localRenderChecksum(asset),
|
||||
);
|
||||
} else {
|
||||
final String assetId;
|
||||
final String thumbhash;
|
||||
@@ -187,7 +193,7 @@ ImageProvider getFullImageProvider(
|
||||
ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution, bool edited = true}) {
|
||||
if (_shouldUseLocalAsset(asset)) {
|
||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
|
||||
return LocalThumbProvider(id: id, size: size, assetType: asset.type);
|
||||
return LocalThumbProvider(id: id, size: size, assetType: asset.type, checksum: _localRenderChecksum(asset));
|
||||
}
|
||||
|
||||
final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId;
|
||||
@@ -195,7 +201,14 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai
|
||||
return assetId != null ? RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: thumbhash, edited: edited) : null;
|
||||
}
|
||||
|
||||
// Cache key for rendering the LOCAL bytes: a remote linked via priorRemoteId carries
|
||||
// the server checksum, which doesn't move when the on-device bytes change again.
|
||||
String? _localRenderChecksum(BaseAsset asset) => asset is RemoteAsset ? asset.localChecksum : asset.checksum;
|
||||
|
||||
bool _shouldUseLocalAsset(BaseAsset asset) =>
|
||||
asset.hasLocal &&
|
||||
(!asset.hasRemote || !SettingsRepository.instance.appConfig.image.preferRemote) &&
|
||||
!asset.isEdited;
|
||||
!asset.isEdited &&
|
||||
// A prior-linked local that hasn't rehashed yet has no trustworthy cache key
|
||||
// (its bytes may differ from the server checksum) — render the remote instead.
|
||||
(asset is! RemoteAsset || asset.localChecksum != null);
|
||||
|
||||
@@ -14,7 +14,11 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
|
||||
final Size size;
|
||||
final AssetType assetType;
|
||||
|
||||
LocalThumbProvider({required this.id, required this.assetType, this.size = kThumbnailResolution});
|
||||
// an on-device edit/revert keeps the same id but changes the bytes, so the checksum
|
||||
// is what keys a cached thumbnail to its render.
|
||||
final String? checksum;
|
||||
|
||||
LocalThumbProvider({required this.id, required this.assetType, this.checksum, this.size = kThumbnailResolution});
|
||||
|
||||
@override
|
||||
Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) {
|
||||
@@ -44,13 +48,13 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
|
||||
return true;
|
||||
}
|
||||
if (other is LocalThumbProvider) {
|
||||
return id == other.id;
|
||||
return id == other.id && checksum == other.checksum;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
int get hashCode => id.hashCode ^ checksum.hashCode;
|
||||
}
|
||||
|
||||
class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProvider>
|
||||
@@ -59,8 +63,15 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
||||
final Size size;
|
||||
final AssetType assetType;
|
||||
final bool isAnimated;
|
||||
final String? checksum;
|
||||
|
||||
LocalFullImageProvider({required this.id, required this.assetType, required this.size, required this.isAnimated});
|
||||
LocalFullImageProvider({
|
||||
required this.id,
|
||||
required this.assetType,
|
||||
required this.size,
|
||||
required this.isAnimated,
|
||||
this.checksum,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
@@ -73,7 +84,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
||||
return AnimatedImageStreamCompleter(
|
||||
stream: _animatedCodec(key, decode),
|
||||
scale: 1.0,
|
||||
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
|
||||
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType, checksum: key.checksum)),
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<String>('Id', key.id),
|
||||
@@ -86,7 +97,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
||||
|
||||
return OneFramePlaceholderImageStreamCompleter(
|
||||
_codec(key, decode),
|
||||
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
|
||||
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType, checksum: key.checksum)),
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<String>('Id', key.id),
|
||||
@@ -163,11 +174,11 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
||||
return true;
|
||||
}
|
||||
if (other is LocalFullImageProvider) {
|
||||
return id == other.id && size == other.size && isAnimated == other.isAnimated;
|
||||
return id == other.id && size == other.size && isAnimated == other.isAnimated && checksum == other.checksum;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode ^ size.hashCode ^ isAnimated.hashCode;
|
||||
int get hashCode => id.hashCode ^ size.hashCode ^ isAnimated.hashCode ^ checksum.hashCode;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/utils/upload_speed_calculator.dart';
|
||||
@@ -13,6 +12,12 @@ import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/services/background_upload.service.dart';
|
||||
|
||||
/// Max foreground-backup passes per run. iOS burst stacking needs a pass for the
|
||||
/// representative, then another for its now-eligible members; a few more cover
|
||||
/// multi-burst captures. Bounded so a stuck candidate can't loop forever — the
|
||||
/// no-progress break usually stops sooner.
|
||||
const _kMaxBackupPasses = 6;
|
||||
|
||||
class EnqueueStatus {
|
||||
final int enqueueCount;
|
||||
final int totalCount;
|
||||
@@ -209,6 +214,9 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
final BackgroundUploadService _backgroundUploadService;
|
||||
final UploadSpeedManager _uploadSpeedManager;
|
||||
Completer<void>? _cancelToken;
|
||||
// Ids uploaded in the current foreground-backup session, so the multi-pass
|
||||
// loop doesn't re-grab an asset whose remote row hasn't synced back locally yet.
|
||||
final Set<String> _sessionUploadedIds = {};
|
||||
|
||||
final _logger = Logger("DriftBackupNotifier");
|
||||
|
||||
@@ -256,7 +264,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
state = state.copyWith(isSyncing: isSyncing);
|
||||
}
|
||||
|
||||
Future<void> startForegroundBackup(String userId) {
|
||||
Future<void> startForegroundBackup(String userId) async {
|
||||
// Cancel any existing backup before starting a new one
|
||||
if (_cancelToken != null) {
|
||||
stopForegroundBackup();
|
||||
@@ -266,16 +274,44 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
|
||||
_cancelToken = Completer<void>();
|
||||
|
||||
return _foregroundUploadService.uploadCandidates(
|
||||
userId,
|
||||
_cancelToken!,
|
||||
callbacks: UploadCallbacks(
|
||||
onProgress: _handleForegroundBackupProgress,
|
||||
onSuccess: _handleForegroundBackupSuccess,
|
||||
onError: _handleForegroundBackupError,
|
||||
onICloudProgress: _handleICloudProgress,
|
||||
),
|
||||
// Ids uploaded this session: a just-uploaded asset stays a backup candidate
|
||||
// until its remote row syncs back locally, so the next pass would re-grab it
|
||||
// (the server dedups, but it wastes the transfer). Skipping known-uploaded
|
||||
// ids makes the multi-pass loop idempotent. Populated in the success handler.
|
||||
_sessionUploadedIds.clear();
|
||||
final callbacks = UploadCallbacks(
|
||||
onProgress: _handleForegroundBackupProgress,
|
||||
onSuccess: _handleForegroundBackupSuccess,
|
||||
onError: _handleForegroundBackupError,
|
||||
onICloudProgress: _handleICloudProgress,
|
||||
);
|
||||
|
||||
// iOS burst stacking needs more than one pass: a burst's representative
|
||||
// uploads first, then its members become eligible (they stack under it). So
|
||||
// loop only while a pass still has burst frames to work through - a non-burst
|
||||
// library (and all of Android) finishes in a single pass with no extra
|
||||
// candidate query. Capped so a stuck candidate can't spin forever. `myToken`
|
||||
// is captured once: if another backup supersedes this one (restart installs
|
||||
// a fresh token), `identical` fails and this loop exits instead of running
|
||||
// concurrently against the shared session state. `skipIds` keeps a just-
|
||||
// uploaded asset (remote row not synced back yet) from being re-grabbed.
|
||||
final myToken = _cancelToken;
|
||||
for (var pass = 0; pass < _kMaxBackupPasses; pass++) {
|
||||
if (!mounted || myToken == null || myToken.isCompleted || !identical(_cancelToken, myToken)) {
|
||||
break;
|
||||
}
|
||||
final result = await _foregroundUploadService.uploadCandidates(
|
||||
userId,
|
||||
myToken,
|
||||
callbacks: callbacks,
|
||||
skipIds: _sessionUploadedIds,
|
||||
);
|
||||
// Nothing attempted (all remaining candidates already uploaded this
|
||||
// session), or nothing burst-related to unblock a later pass → done.
|
||||
if (result.attempted == 0 || !result.hadBurst) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void stopForegroundBackup() {
|
||||
@@ -334,6 +370,12 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
}
|
||||
|
||||
void _handleForegroundBackupSuccess(String localAssetId, String remoteAssetId) {
|
||||
_sessionUploadedIds.add(localAssetId);
|
||||
if (!mounted) {
|
||||
// Upload finished after the notifier was disposed (e.g. navigated away
|
||||
// mid-backup). Keep the session-id bookkeeping above but don't touch state.
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(backupCount: state.backupCount + 1, remainderCount: state.remainderCount - 1);
|
||||
_uploadSpeedManager.removeTask(localAssetId);
|
||||
|
||||
@@ -380,19 +422,19 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
}
|
||||
_logger.info("Start background backup sequence");
|
||||
state = state.copyWith(error: BackupError.none);
|
||||
final tasks = await _backgroundUploadService.getActiveTasks(kBackupGroup);
|
||||
final pending = await _backgroundUploadService.getActiveBackupTaskCount();
|
||||
if (!mounted) {
|
||||
_logger.warning("Skip handleBackupResume (post-call): notifier disposed");
|
||||
return;
|
||||
}
|
||||
_logger.info("Found ${tasks.length} pending tasks");
|
||||
_logger.info("Found $pending pending tasks");
|
||||
|
||||
if (tasks.isEmpty) {
|
||||
if (pending == 0) {
|
||||
_logger.info("No pending tasks, starting new upload");
|
||||
return _backgroundUploadService.uploadBackupCandidates(userId);
|
||||
}
|
||||
|
||||
_logger.info("Resuming upload ${tasks.length} assets");
|
||||
_logger.info("Resuming upload $pending assets");
|
||||
return _backgroundUploadService.resume();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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/local_sync.service.dart';
|
||||
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
|
||||
@@ -11,6 +12,8 @@ import 'package:immich_mobile/providers/infrastructure/asset.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/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/stack.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
|
||||
@@ -46,6 +49,15 @@ 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(
|
||||
(ref) => HashService(
|
||||
localAlbumRepository: ref.watch(localAlbumRepository),
|
||||
@@ -53,5 +65,7 @@ final hashServiceProvider = Provider(
|
||||
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||
cancellation: ref.watch(cancellationProvider),
|
||||
stackRepository: ref.watch(driftStackProvider),
|
||||
assetApiRepository: ref.watch(assetApiRepositoryProvider),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -53,9 +53,18 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
);
|
||||
final List<dynamic> _batchedAssetUploadReady = [];
|
||||
|
||||
// Batches a burst of stack updates (one per uploaded edit) into a single
|
||||
// remote sync. Kept separate from _batchDebouncer so the two don't overwrite
|
||||
// each other's pending action.
|
||||
final Debouncer _stackUpdateDebouncer = Debouncer(
|
||||
interval: const Duration(seconds: 2),
|
||||
maxWaitTime: const Duration(seconds: 5),
|
||||
);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_batchDebouncer.dispose();
|
||||
_stackUpdateDebouncer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -105,6 +114,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2);
|
||||
socket.on('on_config_update', _handleOnConfigUpdate);
|
||||
socket.on('on_new_release', _handleReleaseUpdates);
|
||||
socket.on('on_asset_stack_update', _handleAssetStackUpdate);
|
||||
} catch (e) {
|
||||
dPrint(() => "[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
|
||||
}
|
||||
@@ -188,6 +198,14 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
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. Debounced so a
|
||||
// backup of many edits doesn't trigger a sync per event.
|
||||
void _handleAssetStackUpdate(dynamic _) {
|
||||
_stackUpdateDebouncer.run(() => _ref.read(backgroundSyncProvider).runFreshRemoteSync());
|
||||
}
|
||||
|
||||
void _processBatchedAssetUploadReadyV1() {
|
||||
if (_batchedAssetUploadReady.isEmpty) {
|
||||
return;
|
||||
|
||||
@@ -73,6 +73,10 @@ class AssetApiRepository extends ApiRepository {
|
||||
return _stacksApi.deleteStacks(BulkIdsDto(ids: ids));
|
||||
}
|
||||
|
||||
Future<void> setStackPrimary(String stackId, String primaryAssetId) async {
|
||||
await _stacksApi.updateStack(stackId, StackUpdateDto(primaryAssetId: Optional.present(primaryAssetId)));
|
||||
}
|
||||
|
||||
Future<Response> downloadAsset(String id, {required bool edited}) {
|
||||
return _api.downloadAssetWithHttpInfo(id, edited: edited);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@ class UploadRepository {
|
||||
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
||||
taskProgressCallback: (update) => onTaskProgress?.call(update),
|
||||
);
|
||||
FileDownloader().registerCallbacks(
|
||||
group: kBackupEditPairGroup,
|
||||
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
||||
taskProgressCallback: (update) => onTaskProgress?.call(update),
|
||||
);
|
||||
FileDownloader().registerCallbacks(
|
||||
group: kManualUploadGroup,
|
||||
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
||||
@@ -62,6 +67,11 @@ class UploadRepository {
|
||||
return FileDownloader().allTasks(group: group);
|
||||
}
|
||||
|
||||
/// The ENQUEUED or RUNNING task with this id, if any.
|
||||
Future<Task?> getTaskById(String taskId) {
|
||||
return FileDownloader().taskForId(taskId);
|
||||
}
|
||||
|
||||
Future<void> start() {
|
||||
return FileDownloader().start();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
|
||||
/// The iOS mobile-app metadata multipart field, shared by the foreground and
|
||||
/// background upload paths so the payload only has one definition. null when
|
||||
/// there's nothing to attach. Pass [adjustmentTime] only for an edited render;
|
||||
/// the unedited base carries none.
|
||||
String? cloudMetadataJson({
|
||||
required String? cloudId,
|
||||
required DateTime createdAt,
|
||||
String? adjustmentTime,
|
||||
String? latitude,
|
||||
String? longitude,
|
||||
}) {
|
||||
if (!CurrentPlatform.isIOS || cloudId == null) {
|
||||
return null;
|
||||
}
|
||||
return jsonEncode([
|
||||
RemoteAssetMetadataItem(
|
||||
key: RemoteAssetMetadataKey.mobileApp,
|
||||
value: RemoteAssetMobileAppMetadata(
|
||||
cloudId: cloudId,
|
||||
createdAt: createdAt.toIso8601String(),
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/stack.repository.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: positively not edited, identical bytes, or the
|
||||
/// original resource simply isn't there to recover.
|
||||
class NoEditPair extends EditPairPlan {
|
||||
const NoEditPair();
|
||||
}
|
||||
|
||||
/// Can't be decided right now: the prior upload sits in the server trash, or the
|
||||
/// adjustment metadata / original couldn't be read (offloaded to iCloud, network
|
||||
/// off, stalled read). Skip the asset this cycle — it stays a candidate and
|
||||
/// resolves once conditions change. Uploading anyway would mark the edit synced
|
||||
/// and permanently drop the original from backup.
|
||||
class DeferEditPair extends EditPairPlan {
|
||||
const DeferEditPair();
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// Live photo edit: upload the original pair first (the [still] always, the [video]
|
||||
/// when one survives) and stack the edited live photo onto the original still.
|
||||
/// [video] is null when the original has no paired video to recover (e.g. the edit
|
||||
/// turned Live off), which degrades to a still-only parent.
|
||||
class UploadBaseLivePhotoFirst extends EditPairPlan {
|
||||
final BaseResource still;
|
||||
final BaseResource? video;
|
||||
const UploadBaseLivePhotoFirst(this.still, this.video);
|
||||
}
|
||||
|
||||
/// Works out how an edited photo should stack: reuse a prior upload, upload the
|
||||
/// original first, do nothing, or defer to a later cycle. Shared by the foreground
|
||||
/// and background upload paths. The caller already checked it's iOS; pass
|
||||
/// [isLivePhoto] for a live photo so the original pair (still + paired video) is
|
||||
/// read instead of a single still.
|
||||
///
|
||||
/// A photo that was never edited only carries the capture-time Photographic Style,
|
||||
/// which iOS stamps at [LocalAsset.createdAt]; a real edit moves [LocalAsset.adjustmentTime]
|
||||
/// later. When they match (or there's no adjustment at all) there's nothing to stack, so
|
||||
/// we skip the native read. Anything that moved the timestamp (edit, retime, revert) falls
|
||||
/// through to [NativeSyncApi.getBaseResource] / [NativeSyncApi.getBaseLivePhoto], which read
|
||||
/// the adjustment plist and decide.
|
||||
Future<EditPairPlan> resolveEditPair(
|
||||
NativeSyncApi nativeSyncApi,
|
||||
LocalAsset asset, {
|
||||
required DriftStackRepository stackRepository,
|
||||
required String? ownerId,
|
||||
Logger? log,
|
||||
bool isLivePhoto = false,
|
||||
}) async {
|
||||
final priorRemoteId = asset.priorRemoteId;
|
||||
if (priorRemoteId != null) {
|
||||
PriorState priorState;
|
||||
try {
|
||||
priorState = await stackRepository.priorState(priorRemoteId);
|
||||
} catch (error, stack) {
|
||||
log?.warning(() => "Failed to check prior remote $priorRemoteId for ${asset.id}", error, stack);
|
||||
return const DeferEditPair();
|
||||
}
|
||||
switch (priorState) {
|
||||
case PriorState.live:
|
||||
return AbsorbIntoPrior(priorRemoteId);
|
||||
case PriorState.trashed:
|
||||
// The prior sits in the server trash. Re-uploading the base would just
|
||||
// dedupe onto the trashed row and the edit would 400 stacking onto it
|
||||
// ("Cannot stack onto a trashed or missing asset"), so wait: restore
|
||||
// makes it live (absorb), emptying trash makes it missing (rebuild).
|
||||
return const DeferEditPair();
|
||||
case PriorState.missing:
|
||||
// No synced row for the stamp. With syncedChecksum unset a chain is
|
||||
// mid-flight and the row just hasn't synced back yet — resume onto it.
|
||||
// With syncedChecksum set the completed prior has since vanished from
|
||||
// the server (hard delete), so fall through and re-resolve from scratch.
|
||||
if (asset.syncedChecksum == null) {
|
||||
return AbsorbIntoPrior(priorRemoteId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!_mightBeEdited(asset)) {
|
||||
return const NoEditPair();
|
||||
}
|
||||
|
||||
if (isLivePhoto) {
|
||||
return _resolveLivePair(nativeSyncApi, asset, stackRepository: stackRepository, ownerId: ownerId, log: log);
|
||||
}
|
||||
|
||||
BaseResource? base;
|
||||
try {
|
||||
// Native bounds each resource read (classify + still) at 120s idle; the outer
|
||||
// timeout only catches a reply that never comes back across the platform channel.
|
||||
base = await nativeSyncApi.getBaseResource(asset.id, allowNetworkAccess: true).timeout(_baseReadTimeout);
|
||||
} catch (error, stack) {
|
||||
// Transient (timeout, unreadable plist, iCloud hiccup): defer instead of
|
||||
// uploading the edit standalone, which would permanently skip the original.
|
||||
log?.warning(() => "Failed to read base resource for ${asset.id}, deferring", error, stack);
|
||||
return const DeferEditPair();
|
||||
}
|
||||
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) {
|
||||
await _deleteTemp(base.path);
|
||||
return const NoEditPair();
|
||||
}
|
||||
|
||||
switch (await _planForExistingBase(stackRepository, base.sha1, ownerId, log: log)) {
|
||||
case AbsorbIntoPrior(:final parentId):
|
||||
await _deleteTemp(base.path);
|
||||
return AbsorbIntoPrior(parentId);
|
||||
case DeferEditPair():
|
||||
await _deleteTemp(base.path);
|
||||
return const DeferEditPair();
|
||||
default:
|
||||
return UploadBaseFirst(base);
|
||||
}
|
||||
}
|
||||
|
||||
/// The base bytes may already be on the server: backed up before the stamps
|
||||
/// existed, by another install, or after the stamps were belt-cleared. Absorb
|
||||
/// straight onto a live copy instead of re-uploading bytes the server has;
|
||||
/// defer while that copy sits in the trash — uploading would just dedupe onto
|
||||
/// the trashed row and the stack would 400. null = no copy, upload the base.
|
||||
Future<EditPairPlan?> _planForExistingBase(
|
||||
DriftStackRepository stackRepository,
|
||||
String baseSha1,
|
||||
String? ownerId, {
|
||||
Logger? log,
|
||||
}) async {
|
||||
if (ownerId == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final dup = await stackRepository.remoteByChecksum(baseSha1, ownerId);
|
||||
return switch (dup.state) {
|
||||
PriorState.live => AbsorbIntoPrior(dup.remoteId!),
|
||||
PriorState.trashed => const DeferEditPair(),
|
||||
PriorState.missing => null,
|
||||
};
|
||||
} catch (error, stack) {
|
||||
log?.warning(() => "Failed to check base checksum against synced remotes", error, stack);
|
||||
return const DeferEditPair();
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the original pair of an edited live photo. Skips stacking when the original
|
||||
/// still matches the current bytes (e.g. a video-only trim) — the base still would
|
||||
/// dedupe to the edit itself on the server, so it can't be its own stack parent; the
|
||||
/// edit just uploads normally. Temps are dropped on every non-stack outcome.
|
||||
Future<EditPairPlan> _resolveLivePair(
|
||||
NativeSyncApi nativeSyncApi,
|
||||
LocalAsset asset, {
|
||||
required DriftStackRepository stackRepository,
|
||||
required String? ownerId,
|
||||
Logger? log,
|
||||
}) async {
|
||||
BaseLivePhoto? live;
|
||||
try {
|
||||
// Up to three native reads here (classify + still + paired video), 120s idle each.
|
||||
live = await nativeSyncApi.getBaseLivePhoto(asset.id, allowNetworkAccess: true).timeout(_baseLiveReadTimeout);
|
||||
} catch (error, stack) {
|
||||
log?.warning(() => "Failed to read base live photo for ${asset.id}, deferring", error, stack);
|
||||
return const DeferEditPair();
|
||||
}
|
||||
if (live == null) {
|
||||
return const NoEditPair();
|
||||
}
|
||||
|
||||
if (live.still.sha1 == asset.checksum) {
|
||||
await _deleteTemp(live.still.path);
|
||||
await _deleteTemp(live.video?.path);
|
||||
return const NoEditPair();
|
||||
}
|
||||
|
||||
switch (await _planForExistingBase(stackRepository, live.still.sha1, ownerId, log: log)) {
|
||||
case AbsorbIntoPrior(:final parentId):
|
||||
await _deleteTemp(live.still.path);
|
||||
await _deleteTemp(live.video?.path);
|
||||
return AbsorbIntoPrior(parentId);
|
||||
case DeferEditPair():
|
||||
await _deleteTemp(live.still.path);
|
||||
await _deleteTemp(live.video?.path);
|
||||
return const DeferEditPair();
|
||||
default:
|
||||
return UploadBaseLivePhotoFirst(live.still, live.video);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteTemp(String? path) async {
|
||||
if (path == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await File(path).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// iOS stamps the capture-time Photographic Style at the creation time and moves the
|
||||
/// adjustment timestamp on any later change. A gap past a small tolerance (capture jitter
|
||||
/// is sub-second, real edits are seconds apart) is worth a native check; no adjustment at
|
||||
/// all means the photo was never touched.
|
||||
bool _mightBeEdited(LocalAsset asset) {
|
||||
final adjustedAt = asset.adjustmentTime;
|
||||
if (adjustedAt == null) {
|
||||
return false;
|
||||
}
|
||||
return adjustedAt.difference(asset.createdAt).inSeconds.abs() > _editTimestampToleranceSeconds;
|
||||
}
|
||||
|
||||
const _editTimestampToleranceSeconds = 2;
|
||||
// Generous on purpose: the native idle watchdog (120s without a chunk) owns
|
||||
// stall detection, so these only catch a reply lost on the platform channel —
|
||||
// a tight bound here would kill big-but-flowing iCloud downloads.
|
||||
const _baseReadTimeout = Duration(minutes: 30);
|
||||
const _baseLiveReadTimeout = Duration(minutes: 45);
|
||||
@@ -1,23 +1,31 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.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/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/extensions/network_capability_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_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/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/stack.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/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/stack.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/upload.repository.dart';
|
||||
import 'package:immich_mobile/services/cloud_metadata.dart';
|
||||
import 'package:immich_mobile/services/edit_pair.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
||||
@@ -39,6 +47,10 @@ final foregroundUploadServiceProvider = Provider((ref) {
|
||||
ref.watch(backupRepositoryProvider),
|
||||
ref.watch(connectivityApiProvider),
|
||||
ref.watch(assetMediaRepositoryProvider),
|
||||
ref.watch(nativeSyncApiProvider),
|
||||
ref.watch(localAssetRepository),
|
||||
ref.watch(editRevertServiceProvider),
|
||||
ref.watch(driftStackProvider),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -54,6 +66,10 @@ class ForegroundUploadService {
|
||||
this._backupRepository,
|
||||
this._connectivityApi,
|
||||
this._assetMediaRepository,
|
||||
this._nativeSyncApi,
|
||||
this._localAssetRepository,
|
||||
this._editRevertService,
|
||||
this._stackRepository,
|
||||
);
|
||||
|
||||
final UploadRepository _uploadRepository;
|
||||
@@ -61,6 +77,10 @@ class ForegroundUploadService {
|
||||
final DriftBackupRepository _backupRepository;
|
||||
final ConnectivityApi _connectivityApi;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final EditRevertService _editRevertService;
|
||||
final DriftStackRepository _stackRepository;
|
||||
final Logger _logger = Logger('ForegroundUploadService');
|
||||
|
||||
bool shouldAbortUpload = false;
|
||||
@@ -74,16 +94,31 @@ class ForegroundUploadService {
|
||||
}
|
||||
|
||||
/// Bulk upload of backup candidates from selected albums
|
||||
Future<void> uploadCandidates(
|
||||
/// Returns the number of candidates this pass attempted (after [skipIds]
|
||||
/// filtering), so the multi-pass driver can stop as soon as a pass has nothing
|
||||
/// left to do instead of walking the candidate set one extra time.
|
||||
Future<({int attempted, bool hadBurst})> uploadCandidates(
|
||||
String userId,
|
||||
Completer<void> cancelToken, {
|
||||
UploadCallbacks callbacks = const UploadCallbacks(),
|
||||
bool useSequentialUpload = false,
|
||||
Set<String>? skipIds,
|
||||
}) async {
|
||||
final candidates = await _backupRepository.getCandidates(userId);
|
||||
if (candidates.isEmpty) {
|
||||
return;
|
||||
var candidates = await _backupRepository.getCandidates(userId);
|
||||
if (skipIds != null && skipIds.isNotEmpty) {
|
||||
// Multi-pass driver passes the ids it already uploaded this session: a
|
||||
// freshly uploaded asset stays a candidate until its remote row syncs back
|
||||
// locally, so skipping it here stops the next pass re-uploading it (the
|
||||
// server would just dedup it, wasting the transfer).
|
||||
candidates = candidates.where((a) => !skipIds.contains(a.id)).toList();
|
||||
}
|
||||
if (candidates.isEmpty) {
|
||||
return (attempted: 0, hadBurst: false);
|
||||
}
|
||||
// Burst frames may unblock more candidates next pass (a member only becomes
|
||||
// eligible once its representative has uploaded), so the driver keeps going.
|
||||
// Without burst frames this pass is final - no wasted follow-up query.
|
||||
final hadBurst = candidates.any((a) => a.burstId != null);
|
||||
|
||||
final networkCapabilities = await _connectivityApi.getCapabilities();
|
||||
final hasWifi = networkCapabilities.isUnmetered;
|
||||
@@ -102,6 +137,7 @@ class ForegroundUploadService {
|
||||
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
|
||||
);
|
||||
}
|
||||
return (attempted: candidates.length, hadBurst: hadBurst);
|
||||
}
|
||||
|
||||
/// Sequential upload - used for background isolate where concurrent HTTP clients may cause issues
|
||||
@@ -142,7 +178,7 @@ class ForegroundUploadService {
|
||||
await _executeWithWorkerPool<LocalAsset>(
|
||||
items: localAssets,
|
||||
cancelToken: cancelToken,
|
||||
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
|
||||
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks, surfaceDefers: true),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -232,11 +268,33 @@ class ForegroundUploadService {
|
||||
await Future.wait(workerFutures);
|
||||
}
|
||||
|
||||
// Multipart fields common to every asset upload. deviceAssetId/deviceId are
|
||||
// required by server v2.7.5 and below (drop in v4.0 per #27818). Returns a
|
||||
// fresh mutable map so callers can add stackParentId/metadata/etc.
|
||||
Map<String, String> _baseUploadFields(LocalAsset asset) => {
|
||||
'deviceAssetId': asset.localId!,
|
||||
'deviceId': Store.get(StoreKey.deviceId),
|
||||
'fileCreatedAt': asset.createdAt.toUtc().toIso8601String(),
|
||||
'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(),
|
||||
'isFavorite': asset.isFavorite.toString(),
|
||||
'duration': (asset.durationMs ?? 0).toString(),
|
||||
};
|
||||
|
||||
Future<void> _uploadSingleAsset(
|
||||
LocalAsset asset,
|
||||
Completer<void>? cancelToken, {
|
||||
required UploadCallbacks callbacks,
|
||||
bool surfaceDefers = false,
|
||||
}) async {
|
||||
// iOS burst non-representative: photo_manager can't resolve it (the entity
|
||||
// lookup below returns null), so fetch its bytes natively and upload it
|
||||
// stacked under the burst anchor. Burst frames are never edited or live, so
|
||||
// they skip the edit-pair + live-photo handling entirely.
|
||||
if (CurrentPlatform.isIOS && asset.burstId != null && !asset.isBurstRepresentative) {
|
||||
await _uploadBurstMember(asset, cancelToken, callbacks: callbacks);
|
||||
return;
|
||||
}
|
||||
|
||||
File? file;
|
||||
File? livePhotoFile;
|
||||
|
||||
@@ -250,6 +308,55 @@ class ForegroundUploadService {
|
||||
return;
|
||||
}
|
||||
|
||||
// A reverted iOS edit flips the stack back to the original and skips the upload.
|
||||
// Works for live photos too — getEditState reads the adjustment plist, which is
|
||||
// media-agnostic. Report the flipped-to base, not the pre-flip prior (the edit
|
||||
// being reverted away) — album-add consumers link whatever id this reports.
|
||||
if (CurrentPlatform.isIOS && asset.priorRemoteId != null) {
|
||||
final revertedTo = await _editRevertService.tryHandleRevert(asset);
|
||||
if (revertedTo != null) {
|
||||
callbacks.onSuccess?.call(asset.localId!, revertedTo);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final fields = _baseUploadFields(asset);
|
||||
|
||||
// Edit pair: upload the unedited original first and stack the edit onto it. For a
|
||||
// live photo that's the original still+video pair; this upload stays the edit and
|
||||
// its own edited motion uploads after, below. Resolved before anything is
|
||||
// materialized so a deferred or failed pair doesn't burn an iCloud download or a
|
||||
// motion upload every retry cycle, and before the edit's metadata is added so the
|
||||
// base isn't stamped with the edit's adjustmentTime.
|
||||
final base = await _resolveStackParent(asset, Map.of(fields), cancelToken, isLivePhoto: entity.isLivePhoto);
|
||||
if (base.deferred) {
|
||||
// Undecidable right now (prior in server trash, or the original couldn't be
|
||||
// read). The asset stays a candidate; auto backup retries silently, a manual
|
||||
// upload tells the user why nothing happened.
|
||||
_logger.fine(() => "Deferring upload for ${asset.localId}: edit pair undecidable this cycle");
|
||||
if (surfaceDefers) {
|
||||
callbacks.onError?.call(asset.localId!, "upload_deferred_edit_pair".t());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (base.baseFailed) {
|
||||
// The original couldn't be uploaded. Don't upload the edit on its own and mark
|
||||
// it synced — that would permanently drop the original from backup. Leave the
|
||||
// whole pair as a candidate to retry next cycle.
|
||||
_logger.warning(() => "Base upload failed for ${asset.localId}, retrying the pair later");
|
||||
if (base.isCancelled) {
|
||||
shouldAbortUpload = true;
|
||||
return;
|
||||
}
|
||||
if (base.errorMessage != null) {
|
||||
callbacks.onError?.call(asset.localId!, base.errorMessage!);
|
||||
if (base.errorMessage == _kQuotaError) {
|
||||
shouldAbortUpload = true;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final isAvailableLocally = await _storageRepository.isAssetAvailableLocally(asset.id);
|
||||
|
||||
if (!isAvailableLocally && CurrentPlatform.isIOS) {
|
||||
@@ -317,19 +424,13 @@ class ForegroundUploadService {
|
||||
}
|
||||
|
||||
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
|
||||
final deviceId = Store.get(StoreKey.deviceId);
|
||||
|
||||
final fields = {
|
||||
// deviceAssetId/deviceId required by server v2.7.5 and below (drop in v4.0 per #27818).
|
||||
'deviceAssetId': asset.localId!,
|
||||
'deviceId': deviceId,
|
||||
'fileCreatedAt': asset.createdAt.toUtc().toIso8601String(),
|
||||
'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(),
|
||||
'isFavorite': asset.isFavorite.toString(),
|
||||
'duration': (asset.durationMs ?? 0).toString(),
|
||||
};
|
||||
if (base.stackParentId != null) {
|
||||
fields['stackParentId'] = base.stackParentId!;
|
||||
}
|
||||
|
||||
// Upload live photo video first if available
|
||||
// The edit's own motion video, uploaded hidden so it never flashes onto the
|
||||
// timeline before the still below links it.
|
||||
String? livePhotoVideoId;
|
||||
if (entity.isLivePhoto && livePhotoFile != null) {
|
||||
final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path));
|
||||
@@ -338,7 +439,7 @@ class ForegroundUploadService {
|
||||
final livePhotoResult = await _uploadRepository.uploadFile(
|
||||
file: livePhotoFile,
|
||||
originalFileName: livePhotoTitle,
|
||||
fields: fields,
|
||||
fields: {...fields, 'visibility': kHiddenVisibility}..remove('stackParentId'),
|
||||
cancelToken: cancelToken,
|
||||
onProgress: onProgress != null
|
||||
? (bytes, totalBytes) => onProgress(asset.localId!, livePhotoTitle, bytes, totalBytes)
|
||||
@@ -348,6 +449,13 @@ class ForegroundUploadService {
|
||||
|
||||
if (livePhotoResult.isSuccess && livePhotoResult.remoteAssetId != null) {
|
||||
livePhotoVideoId = livePhotoResult.remoteAssetId;
|
||||
} else if (livePhotoResult.isCancelled) {
|
||||
shouldAbortUpload = true;
|
||||
return;
|
||||
} else if (livePhotoResult.errorMessage == _kQuotaError) {
|
||||
callbacks.onError?.call(asset.localId!, livePhotoResult.errorMessage!);
|
||||
shouldAbortUpload = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,19 +464,9 @@ class ForegroundUploadService {
|
||||
}
|
||||
|
||||
// Add cloudId metadata only to the still image, not the motion video, becasue when the sync id happens, the motion video can get associated with the wrong still image.
|
||||
if (CurrentPlatform.isIOS && asset.cloudId != null) {
|
||||
fields['metadata'] = jsonEncode([
|
||||
RemoteAssetMetadataItem(
|
||||
key: RemoteAssetMetadataKey.mobileApp,
|
||||
value: RemoteAssetMobileAppMetadata(
|
||||
cloudId: asset.cloudId,
|
||||
createdAt: asset.createdAt.toIso8601String(),
|
||||
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
|
||||
latitude: asset.latitude?.toString(),
|
||||
longitude: asset.longitude?.toString(),
|
||||
),
|
||||
),
|
||||
]);
|
||||
final metadata = _cloudMetadata(asset, includeAdjustment: true);
|
||||
if (metadata != null) {
|
||||
fields['metadata'] = metadata;
|
||||
}
|
||||
|
||||
final onProgress = callbacks.onProgress;
|
||||
@@ -384,6 +482,18 @@ class ForegroundUploadService {
|
||||
);
|
||||
|
||||
if (result.isSuccess && result.remoteAssetId != null) {
|
||||
// Edit stacking is iOS-only; leave the columns untouched on Android so the
|
||||
// candidate guard and merged-timeline hide clause never engage there.
|
||||
if (CurrentPlatform.isIOS) {
|
||||
unawaited(
|
||||
_localAssetRepository
|
||||
.markSynced(asset.localId!, priorRemoteId: result.remoteAssetId!, syncedChecksum: asset.checksum)
|
||||
.catchError(
|
||||
(Object error, StackTrace stack) =>
|
||||
_logger.warning(() => "Failed to mark ${asset.localId} synced", error, stack),
|
||||
),
|
||||
);
|
||||
}
|
||||
callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!);
|
||||
} else if (result.isCancelled) {
|
||||
_logger.warning(() => "Backup was cancelled by the user");
|
||||
@@ -396,9 +506,21 @@ class ForegroundUploadService {
|
||||
|
||||
callbacks.onError?.call(asset.localId!, result.errorMessage!);
|
||||
|
||||
if (result.errorMessage == "Quota has been exceeded!") {
|
||||
if (result.errorMessage == _kQuotaError) {
|
||||
shouldAbortUpload = true;
|
||||
}
|
||||
if (result.errorMessage!.contains(kDeadStackParentError)) {
|
||||
// The stamped prior no longer exists server-side; drop the stamps so
|
||||
// the next cycle re-resolves fresh instead of looping on the dead id.
|
||||
unawaited(
|
||||
_localAssetRepository
|
||||
.clearSyncStamps(asset.localId!)
|
||||
.catchError(
|
||||
(Object error, StackTrace stack) =>
|
||||
_logger.warning(() => "Failed to clear stamps for ${asset.localId}", error, stack),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
_logger.severe(() => "Error backup asset: ${error.toString()}", stackTrace);
|
||||
@@ -415,6 +537,238 @@ class ForegroundUploadService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Foreground upload of an iOS burst non-representative member. Streams the
|
||||
/// same rendition the hash measured ([NativeSyncApi.getCurrentResource]) — the
|
||||
/// member is invisible to photo_manager and matching the hashed bytes keeps it
|
||||
/// merging with its local — and stacks it under the burst anchor with
|
||||
/// `keepPrimary` so the representative stays the primary. Gated until the
|
||||
/// representative has uploaded; returns silently to be retried by the backup
|
||||
/// loop once the anchor resolves.
|
||||
Future<void> _uploadBurstMember(
|
||||
LocalAsset asset,
|
||||
Completer<void>? cancelToken, {
|
||||
required UploadCallbacks callbacks,
|
||||
}) async {
|
||||
final ownerId = Store.tryGet(StoreKey.currentUser)?.id;
|
||||
final parentRemoteId = await _localAssetRepository.getBurstParentRemoteId(asset.burstId!, ownerId: ownerId);
|
||||
if (parentRemoteId == null) {
|
||||
// No anchor. A rep-less group (Keep Everything / re-pick) can never anchor,
|
||||
// so upload the frame standalone instead of gating forever; if a rep still
|
||||
// exists the member is just waiting for it to upload.
|
||||
if (await _localAssetRepository.burstHasRepresentative(asset.burstId!)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
BaseResource? resource;
|
||||
try {
|
||||
resource = await _nativeSyncApi.getCurrentResource(asset.id, allowNetworkAccess: true);
|
||||
} catch (error, stack) {
|
||||
_logger.warning(() => "burst getCurrentResource failed for ${asset.id}: $error", error, stack);
|
||||
}
|
||||
if (resource == null) {
|
||||
callbacks.onError?.call(asset.localId!, "asset_not_found_on_device_ios".t());
|
||||
return;
|
||||
}
|
||||
|
||||
final file = File(resource.path);
|
||||
try {
|
||||
// Rep-less group → standalone (no stack); otherwise stack under the anchor.
|
||||
final fields = _baseUploadFields(asset)..addAll(burstStackFields(parentRemoteId));
|
||||
final metadata = _cloudMetadata(asset, includeAdjustment: true);
|
||||
if (metadata != null) {
|
||||
fields['metadata'] = metadata;
|
||||
}
|
||||
final originalFileName = p.setExtension(
|
||||
await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name,
|
||||
p.extension(resource.path),
|
||||
);
|
||||
|
||||
final onProgress = callbacks.onProgress;
|
||||
final result = await _uploadRepository.uploadFile(
|
||||
file: file,
|
||||
originalFileName: originalFileName,
|
||||
fields: fields,
|
||||
cancelToken: cancelToken,
|
||||
onProgress: onProgress != null
|
||||
? (bytes, totalBytes) => onProgress(asset.localId!, originalFileName, bytes, totalBytes)
|
||||
: null,
|
||||
logContext: 'burstMember[${asset.localId}]',
|
||||
);
|
||||
|
||||
if (result.isSuccess && result.remoteAssetId != null) {
|
||||
unawaited(
|
||||
_localAssetRepository
|
||||
.markSynced(asset.localId!, priorRemoteId: result.remoteAssetId!, syncedChecksum: asset.checksum)
|
||||
.catchError(
|
||||
(Object error, StackTrace stack) =>
|
||||
_logger.warning(() => "Failed to mark burst member ${asset.localId} synced", error, stack),
|
||||
),
|
||||
);
|
||||
callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!);
|
||||
} else if (result.isCancelled) {
|
||||
shouldAbortUpload = true;
|
||||
} else if (result.errorMessage != null) {
|
||||
callbacks.onError?.call(asset.localId!, result.errorMessage!);
|
||||
if (result.errorMessage == _kQuotaError) {
|
||||
shouldAbortUpload = true;
|
||||
}
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
_logger.severe(() => "Error uploading burst member ${asset.localId}: $error", stackTrace);
|
||||
callbacks.onError?.call(asset.localId!, error.toString());
|
||||
} finally {
|
||||
if (Platform.isIOS) {
|
||||
try {
|
||||
await file.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS still-image cloudId metadata as a JSON field, or null when there's
|
||||
/// nothing to attach. The base resource omits adjustmentTime (it's the
|
||||
/// unedited original); the edit includes it.
|
||||
String? _cloudMetadata(LocalAsset asset, {required bool includeAdjustment}) {
|
||||
return cloudMetadataJson(
|
||||
cloudId: asset.cloudId,
|
||||
createdAt: asset.createdAt,
|
||||
adjustmentTime: includeAdjustment ? asset.adjustmentTime?.toIso8601String() : null,
|
||||
latitude: asset.latitude?.toString(),
|
||||
longitude: asset.longitude?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Persists the uploaded base as the asset's prior so an interrupted run resumes
|
||||
/// by stacking onto it (AbsorbIntoPrior) instead of re-reading and re-uploading
|
||||
/// the original. syncedChecksum stays null: the edit itself is still pending.
|
||||
Future<void> _stampBaseUpload(LocalAsset asset, String baseRemoteId) async {
|
||||
try {
|
||||
await _localAssetRepository.markSynced(asset.localId!, priorRemoteId: baseRemoteId, syncedChecksum: null);
|
||||
} catch (error, stack) {
|
||||
_logger.warning(() => "Failed to stamp base upload for ${asset.localId}", error, stack);
|
||||
}
|
||||
}
|
||||
|
||||
/// For an edited iOS photo, uploads the original camera bytes so the edit can
|
||||
/// stack onto it. See [_StackParent] for the outcome.
|
||||
Future<_StackParent> _resolveStackParent(
|
||||
LocalAsset asset,
|
||||
Map<String, String> baseFields,
|
||||
Completer<void>? cancelToken, {
|
||||
bool isLivePhoto = false,
|
||||
}) async {
|
||||
if (!CurrentPlatform.isIOS) {
|
||||
return const _StackParent.none();
|
||||
}
|
||||
|
||||
final plan = await resolveEditPair(
|
||||
_nativeSyncApi,
|
||||
asset,
|
||||
stackRepository: _stackRepository,
|
||||
ownerId: Store.tryGet(StoreKey.currentUser)?.id,
|
||||
log: _logger,
|
||||
isLivePhoto: isLivePhoto,
|
||||
);
|
||||
switch (plan) {
|
||||
case NoEditPair():
|
||||
return const _StackParent.none();
|
||||
case DeferEditPair():
|
||||
return const _StackParent.deferred();
|
||||
case AbsorbIntoPrior(:final parentId):
|
||||
return _StackParent.parent(parentId);
|
||||
case UploadBaseLivePhotoFirst(:final still, :final video):
|
||||
return _uploadBaseLivePair(asset, baseFields, still, video, cancelToken);
|
||||
case UploadBaseFirst(:final base):
|
||||
final baseFile = File(base.path);
|
||||
try {
|
||||
final fields = Map.of(baseFields);
|
||||
final metadata = _cloudMetadata(asset, includeAdjustment: false);
|
||||
if (metadata != null) {
|
||||
fields['metadata'] = metadata;
|
||||
}
|
||||
final result = await _uploadRepository.uploadFile(
|
||||
file: baseFile,
|
||||
originalFileName: p.setExtension(asset.name, p.extension(base.path)),
|
||||
fields: fields,
|
||||
cancelToken: cancelToken,
|
||||
logContext: 'baseResource[${asset.localId}]',
|
||||
);
|
||||
if (result.isSuccess && result.remoteAssetId != null) {
|
||||
await _stampBaseUpload(asset, result.remoteAssetId!);
|
||||
return _StackParent.parent(result.remoteAssetId!);
|
||||
}
|
||||
return _StackParent.failed(errorMessage: result.errorMessage, isCancelled: result.isCancelled);
|
||||
} finally {
|
||||
try {
|
||||
await baseFile.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads the original live pair (paired video then still) so the edited live photo
|
||||
/// can stack onto the original still. Returns the still's remote id as the parent, or
|
||||
/// [_StackParent.failed] if either hop fails so the edit isn't left unstacked.
|
||||
Future<_StackParent> _uploadBaseLivePair(
|
||||
LocalAsset asset,
|
||||
Map<String, String> baseFields,
|
||||
BaseResource still,
|
||||
BaseResource? video,
|
||||
Completer<void>? cancelToken,
|
||||
) async {
|
||||
final stillFile = File(still.path);
|
||||
final videoFile = video != null ? File(video.path) : null;
|
||||
try {
|
||||
final fields = Map.of(baseFields);
|
||||
|
||||
String? baseVideoId;
|
||||
if (videoFile != null) {
|
||||
final videoResult = await _uploadRepository.uploadFile(
|
||||
file: videoFile,
|
||||
originalFileName: p.setExtension(asset.name, p.extension(videoFile.path)),
|
||||
// hidden so the original motion never flashes onto the timeline (copy: the
|
||||
// base still upload below reuses `fields`).
|
||||
fields: {...fields, 'visibility': kHiddenVisibility},
|
||||
cancelToken: cancelToken,
|
||||
logContext: 'baseLiveVideo[${asset.localId}]',
|
||||
);
|
||||
if (!(videoResult.isSuccess && videoResult.remoteAssetId != null)) {
|
||||
return _StackParent.failed(errorMessage: videoResult.errorMessage, isCancelled: videoResult.isCancelled);
|
||||
}
|
||||
baseVideoId = videoResult.remoteAssetId;
|
||||
}
|
||||
|
||||
if (baseVideoId != null) {
|
||||
fields['livePhotoVideoId'] = baseVideoId;
|
||||
}
|
||||
final metadata = _cloudMetadata(asset, includeAdjustment: false);
|
||||
if (metadata != null) {
|
||||
fields['metadata'] = metadata;
|
||||
}
|
||||
|
||||
final stillResult = await _uploadRepository.uploadFile(
|
||||
file: stillFile,
|
||||
originalFileName: p.setExtension(asset.name, p.extension(still.path)),
|
||||
fields: fields,
|
||||
cancelToken: cancelToken,
|
||||
logContext: 'baseLiveStill[${asset.localId}]',
|
||||
);
|
||||
if (stillResult.isSuccess && stillResult.remoteAssetId != null) {
|
||||
await _stampBaseUpload(asset, stillResult.remoteAssetId!);
|
||||
return _StackParent.parent(stillResult.remoteAssetId!);
|
||||
}
|
||||
return _StackParent.failed(errorMessage: stillResult.errorMessage, isCancelled: stillResult.isCancelled);
|
||||
} finally {
|
||||
try {
|
||||
await stillFile.delete();
|
||||
} catch (_) {}
|
||||
try {
|
||||
await videoFile?.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
Future<UploadResult> _uploadSingleFile(
|
||||
File file, {
|
||||
required String deviceAssetId,
|
||||
@@ -461,3 +815,42 @@ class ForegroundUploadService {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Server's quota-rejection message (asset-media.service.ts requireQuota).
|
||||
const String _kQuotaError = "Quota has been exceeded!";
|
||||
|
||||
/// Outcome of resolving an edit's stack parent. [stackParentId] is the remote id
|
||||
/// to stack onto (null when the asset isn't an edit). [baseFailed] is true only
|
||||
/// when the original was found but its upload failed, so the edit must not be
|
||||
/// uploaded on its own; [deferred] means skip the asset this cycle (it stays a
|
||||
/// candidate); [errorMessage]/[isCancelled] carry why a failure happened so the
|
||||
/// caller can surface it and react to quota/cancel like the main upload does.
|
||||
class _StackParent {
|
||||
final String? stackParentId;
|
||||
final bool baseFailed;
|
||||
final bool deferred;
|
||||
final String? errorMessage;
|
||||
final bool isCancelled;
|
||||
|
||||
const _StackParent.none()
|
||||
: stackParentId = null,
|
||||
baseFailed = false,
|
||||
deferred = false,
|
||||
errorMessage = null,
|
||||
isCancelled = false;
|
||||
const _StackParent.parent(String this.stackParentId)
|
||||
: baseFailed = false,
|
||||
deferred = false,
|
||||
errorMessage = null,
|
||||
isCancelled = false;
|
||||
const _StackParent.failed({this.errorMessage, this.isCancelled = false})
|
||||
: stackParentId = null,
|
||||
baseFailed = true,
|
||||
deferred = false;
|
||||
const _StackParent.deferred()
|
||||
: stackParentId = null,
|
||||
baseFailed = false,
|
||||
deferred = true,
|
||||
errorMessage = null,
|
||||
isCancelled = false;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
|
||||
String? getVersionCompatibilityMessage(SemVer serverVersion, SemVer appVersion) {
|
||||
String? getVersionCompatibilityMessage(int _, int appMinor, int _, int serverMinor) {
|
||||
// Add latest compat info up top
|
||||
|
||||
// ensure mobile app major version is not behind server major version
|
||||
if (appVersion.major < serverVersion.major) {
|
||||
return 'Your mobile app version is not compatible with the server! Please update your mobile app to the latest version.';
|
||||
}
|
||||
|
||||
// ensure mobile app major version is not ahead of server major version by more than 1 major version
|
||||
if (appVersion.major > serverVersion.major + 1) {
|
||||
return 'Your server version is not compatible with the mobile app! Please update your server to the latest version.';
|
||||
if (serverMinor < 106 && appMinor >= 106) {
|
||||
return 'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login';
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -26,7 +26,6 @@ import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/provider_utils.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
import 'package:immich_mobile/utils/version_compatibility.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_logo.dart';
|
||||
@@ -89,9 +88,18 @@ class LoginForm extends HookConsumerWidget {
|
||||
checkVersionMismatch() async {
|
||||
try {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
final appSemVer = SemVer.fromString(packageInfo.version);
|
||||
final serverSemVer = serverInfo.serverVersion;
|
||||
warningMessage.value = getVersionCompatibilityMessage(appSemVer, serverSemVer);
|
||||
final appVersion = packageInfo.version;
|
||||
final appMajorVersion = int.parse(appVersion.split('.')[0]);
|
||||
final appMinorVersion = int.parse(appVersion.split('.')[1]);
|
||||
final serverMajorVersion = serverInfo.serverVersion.major;
|
||||
final serverMinorVersion = serverInfo.serverVersion.minor;
|
||||
|
||||
warningMessage.value = getVersionCompatibilityMessage(
|
||||
appMajorVersion,
|
||||
appMinorVersion,
|
||||
serverMajorVersion,
|
||||
serverMinorVersion,
|
||||
);
|
||||
} catch (error) {
|
||||
warningMessage.value = 'Error checking version compatibility';
|
||||
}
|
||||
|
||||
Generated
+23
-3
@@ -1593,6 +1593,9 @@ class AssetsApi {
|
||||
/// * [bool] isFavorite:
|
||||
/// Mark as favorite
|
||||
///
|
||||
/// * [bool] keepPrimary:
|
||||
/// When stacking via stackParentId, keep the parent/existing asset as the stack primary instead of promoting this one. Used by iOS burst frames.
|
||||
///
|
||||
/// * [String] livePhotoVideoId:
|
||||
/// Live photo video ID
|
||||
///
|
||||
@@ -1602,8 +1605,11 @@ class AssetsApi {
|
||||
/// * [MultipartFile] sidecarData:
|
||||
/// Sidecar file data
|
||||
///
|
||||
/// * [String] stackParentId:
|
||||
/// Stack this asset onto the parent asset, with the new asset as the stack primary
|
||||
///
|
||||
/// * [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, Future<void>? abortTrigger, }) async {
|
||||
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, bool? keepPrimary, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, String? stackParentId, AssetVisibility? visibility, Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/assets';
|
||||
|
||||
@@ -1654,6 +1660,10 @@ class AssetsApi {
|
||||
hasFields = true;
|
||||
mp.fields[r'isFavorite'] = parameterToString(isFavorite);
|
||||
}
|
||||
if (keepPrimary != null) {
|
||||
hasFields = true;
|
||||
mp.fields[r'keepPrimary'] = parameterToString(keepPrimary);
|
||||
}
|
||||
if (livePhotoVideoId != null) {
|
||||
hasFields = true;
|
||||
mp.fields[r'livePhotoVideoId'] = parameterToString(livePhotoVideoId);
|
||||
@@ -1667,6 +1677,10 @@ class AssetsApi {
|
||||
mp.fields[r'sidecarData'] = sidecarData.field;
|
||||
mp.files.add(sidecarData);
|
||||
}
|
||||
if (stackParentId != null) {
|
||||
hasFields = true;
|
||||
mp.fields[r'stackParentId'] = parameterToString(stackParentId);
|
||||
}
|
||||
if (visibility != null) {
|
||||
hasFields = true;
|
||||
mp.fields[r'visibility'] = parameterToString(visibility);
|
||||
@@ -1718,6 +1732,9 @@ class AssetsApi {
|
||||
/// * [bool] isFavorite:
|
||||
/// Mark as favorite
|
||||
///
|
||||
/// * [bool] keepPrimary:
|
||||
/// When stacking via stackParentId, keep the parent/existing asset as the stack primary instead of promoting this one. Used by iOS burst frames.
|
||||
///
|
||||
/// * [String] livePhotoVideoId:
|
||||
/// Live photo video ID
|
||||
///
|
||||
@@ -1727,9 +1744,12 @@ class AssetsApi {
|
||||
/// * [MultipartFile] sidecarData:
|
||||
/// Sidecar file data
|
||||
///
|
||||
/// * [String] stackParentId:
|
||||
/// Stack this asset onto the parent asset, with the new asset as the stack primary
|
||||
///
|
||||
/// * [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, Future<void>? abortTrigger, }) 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, abortTrigger: abortTrigger,);
|
||||
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, bool? keepPrimary, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, String? stackParentId, AssetVisibility? visibility, Future<void>? abortTrigger, }) async {
|
||||
final response = await uploadAssetWithHttpInfo(assetData, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, keepPrimary: keepPrimary, livePhotoVideoId: livePhotoVideoId, metadata: metadata, sidecarData: sidecarData, stackParentId: stackParentId, visibility: visibility, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
export 'src/components/close_button.dart';
|
||||
export 'src/components/column_button.dart';
|
||||
export 'src/components/form.dart';
|
||||
export 'src/components/formatted_text.dart';
|
||||
export 'src/components/icon_button.dart';
|
||||
export 'src/components/menu_item.dart';
|
||||
export 'src/components/password_input.dart';
|
||||
export 'src/components/text_button.dart';
|
||||
export 'src/components/text_input.dart';
|
||||
export 'src/components/url_input.dart';
|
||||
export 'src/constants.dart';
|
||||
export 'src/snackbar.dart';
|
||||
export 'src/theme.dart';
|
||||
export 'src/translation.dart';
|
||||
export 'src/types.dart';
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ImmichColorOverride extends InheritedWidget {
|
||||
const ImmichColorOverride({super.key, required this.color, required super.child});
|
||||
|
||||
final Color color;
|
||||
|
||||
static Color? maybeOf(BuildContext context) =>
|
||||
context.dependOnInheritedWidgetOfExactType<ImmichColorOverride>()?.color;
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(ImmichColorOverride oldWidget) => color != oldWidget.color;
|
||||
}
|
||||
@@ -16,9 +16,10 @@ class ImmichCloseButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ImmichIconButton(
|
||||
icon: Icons.close,
|
||||
color: color,
|
||||
variant: variant,
|
||||
onPressed: onPressed ?? () => Navigator.of(context).pop(),
|
||||
);
|
||||
key: key,
|
||||
icon: Icons.close,
|
||||
color: color,
|
||||
variant: variant,
|
||||
onPressed: onPressed ?? () => Navigator.of(context).pop(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/constants.dart';
|
||||
import 'package:immich_ui/src/internal.dart';
|
||||
|
||||
class ImmichColumnButton extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final FutureOr<void> Function() onPressed;
|
||||
final bool disabled;
|
||||
final bool? loading;
|
||||
|
||||
const ImmichColumnButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.disabled = false,
|
||||
this.loading,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ImmichColumnButton> createState() => _ImmichColumnButtonState();
|
||||
}
|
||||
|
||||
class _ImmichColumnButtonState extends State<ImmichColumnButton> {
|
||||
bool _loading = false;
|
||||
bool get _isLoading => widget.loading ?? _loading;
|
||||
|
||||
Future<void> _onPressed() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
await widget.onPressed();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final foreground = context.colorOverride ?? Theme.of(context).colorScheme.onSurface;
|
||||
|
||||
return TextButton(
|
||||
onPressed: widget.disabled || _isLoading ? null : _onPressed,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: foreground,
|
||||
padding: const .symmetric(horizontal: ImmichSpacing.sm, vertical: ImmichSpacing.md),
|
||||
tapTargetSize: .shrinkWrap,
|
||||
shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.xl))),
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: const .new(maxWidth: 90),
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
_isLoading
|
||||
? const SizedBox.square(
|
||||
dimension: ImmichIconSize.md,
|
||||
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.lg),
|
||||
)
|
||||
: Icon(widget.icon, size: ImmichIconSize.md),
|
||||
const SizedBox(height: ImmichSpacing.sm),
|
||||
Text(
|
||||
widget.label,
|
||||
maxLines: 2,
|
||||
textAlign: .center,
|
||||
overflow: .ellipsis,
|
||||
style: const .new(fontSize: ImmichTextSize.label, fontWeight: .w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@ class _ImmichFormState extends State<ImmichForm> {
|
||||
builder: (context, _) => ImmichTextButton(
|
||||
labelText: submitText,
|
||||
icon: widget.submitIcon,
|
||||
variant: .filled,
|
||||
variant: ImmichVariant.filled,
|
||||
loading: _controller.isLoading,
|
||||
onPressed: _controller.submit,
|
||||
disabled: _controller.onSubmit == null,
|
||||
|
||||
@@ -94,12 +94,12 @@ class _ImmichFormattedTextState extends State<ImmichFormattedText> {
|
||||
|
||||
final tag = match.group(1)!.toLowerCase();
|
||||
final content = match.group(2)!;
|
||||
final span = widget.spanBuilder?.call(tag);
|
||||
final style = span?.style ?? _defaultTextStyle(tag);
|
||||
final formattedSpan = (widget.spanBuilder ?? _defaultSpanBuilder)(tag);
|
||||
final style = formattedSpan.style ?? _defaultTextStyle(tag);
|
||||
|
||||
GestureRecognizer? recognizer;
|
||||
if (span?.onTap != null) {
|
||||
recognizer = TapGestureRecognizer()..onTap = span!.onTap;
|
||||
if (formattedSpan.onTap != null) {
|
||||
recognizer = TapGestureRecognizer()..onTap = formattedSpan.onTap;
|
||||
_recognizers.add(recognizer);
|
||||
}
|
||||
spans.add(TextSpan(text: content, style: style, recognizer: recognizer));
|
||||
@@ -114,12 +114,19 @@ class _ImmichFormattedTextState extends State<ImmichFormattedText> {
|
||||
return spans;
|
||||
}
|
||||
|
||||
FormattedSpan _defaultSpanBuilder(String tag) => switch (tag) {
|
||||
'b' => const FormattedSpan(style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
'link' => const FormattedSpan(style: TextStyle(decoration: TextDecoration.underline)),
|
||||
_ when tag.endsWith('-link') => const FormattedSpan(style: TextStyle(decoration: TextDecoration.underline)),
|
||||
_ => const FormattedSpan(),
|
||||
};
|
||||
|
||||
TextStyle? _defaultTextStyle(String tag) => switch (tag) {
|
||||
'b' => const TextStyle(fontWeight: FontWeight.bold),
|
||||
'link' => const TextStyle(decoration: TextDecoration.underline),
|
||||
_ when tag.endsWith('-link') => const TextStyle(decoration: TextDecoration.underline),
|
||||
_ => null,
|
||||
};
|
||||
'b' => const TextStyle(fontWeight: FontWeight.bold),
|
||||
'link' => const TextStyle(decoration: TextDecoration.underline),
|
||||
_ when tag.endsWith('-link') => const TextStyle(decoration: TextDecoration.underline),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -1,80 +1,54 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:immich_ui/src/internal.dart';
|
||||
import 'package:immich_ui/src/types.dart';
|
||||
|
||||
class ImmichIconButton extends StatefulWidget {
|
||||
class ImmichIconButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final FutureOr<void> Function() onPressed;
|
||||
final VoidCallback onPressed;
|
||||
final ImmichVariant variant;
|
||||
final ImmichColor color;
|
||||
final bool disabled;
|
||||
final bool? loading;
|
||||
|
||||
const ImmichIconButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
this.color = .primary,
|
||||
this.variant = .filled,
|
||||
this.color = ImmichColor.primary,
|
||||
this.variant = ImmichVariant.filled,
|
||||
this.disabled = false,
|
||||
this.loading,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ImmichIconButton> createState() => _ImmichIconButtonState();
|
||||
}
|
||||
|
||||
class _ImmichIconButtonState extends State<ImmichIconButton> {
|
||||
bool _loading = false;
|
||||
bool get _isLoading => widget.loading ?? _loading;
|
||||
|
||||
Future<void> _onPressed() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
await widget.onPressed();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final background = switch (widget.variant) {
|
||||
.filled => switch (widget.color) {
|
||||
.primary => colorScheme.primary,
|
||||
.secondary => colorScheme.secondary,
|
||||
},
|
||||
.ghost => Colors.transparent,
|
||||
final background = switch (variant) {
|
||||
ImmichVariant.filled => switch (color) {
|
||||
ImmichColor.primary => colorScheme.primary,
|
||||
ImmichColor.secondary => colorScheme.secondary,
|
||||
},
|
||||
ImmichVariant.ghost => Colors.transparent,
|
||||
};
|
||||
|
||||
final foreground =
|
||||
context.colorOverride ??
|
||||
switch (widget.variant) {
|
||||
.filled => switch (widget.color) {
|
||||
.primary => colorScheme.onPrimary,
|
||||
.secondary => colorScheme.onSecondary,
|
||||
},
|
||||
.ghost => switch (widget.color) {
|
||||
.primary => colorScheme.primary,
|
||||
.secondary => colorScheme.secondary,
|
||||
},
|
||||
};
|
||||
final foreground = switch (variant) {
|
||||
ImmichVariant.filled => switch (color) {
|
||||
ImmichColor.primary => colorScheme.onPrimary,
|
||||
ImmichColor.secondary => colorScheme.onSecondary,
|
||||
},
|
||||
ImmichVariant.ghost => switch (color) {
|
||||
ImmichColor.primary => colorScheme.primary,
|
||||
ImmichColor.secondary => colorScheme.secondary,
|
||||
},
|
||||
};
|
||||
|
||||
final effectiveOnPressed = disabled ? null : onPressed;
|
||||
|
||||
return IconButton(
|
||||
icon: _isLoading
|
||||
? const SizedBox.square(
|
||||
dimension: ImmichIconSize.sm,
|
||||
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.md),
|
||||
)
|
||||
: Icon(widget.icon),
|
||||
onPressed: widget.disabled || _isLoading ? null : _onPressed,
|
||||
style: IconButton.styleFrom(backgroundColor: background, foregroundColor: foreground),
|
||||
icon: Icon(icon),
|
||||
onPressed: effectiveOnPressed,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: background,
|
||||
foregroundColor: foreground,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/constants.dart';
|
||||
import 'package:immich_ui/src/internal.dart';
|
||||
|
||||
class ImmichMenu extends StatefulWidget {
|
||||
final List<Widget> children;
|
||||
final MenuAnchorChildBuilder builder;
|
||||
final MenuStyle? style;
|
||||
final bool consumeOutsideTap;
|
||||
final Widget? child;
|
||||
|
||||
const ImmichMenu({
|
||||
super.key,
|
||||
required this.children,
|
||||
required this.builder,
|
||||
this.style,
|
||||
this.consumeOutsideTap = false,
|
||||
this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ImmichMenu> createState() => _ImmichMenuState();
|
||||
}
|
||||
|
||||
class _ImmichMenuState extends State<ImmichMenu> {
|
||||
final _controller = MenuController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _ImmichMenuScope(
|
||||
controller: _controller,
|
||||
child: MenuAnchor(
|
||||
controller: _controller,
|
||||
style: widget.style,
|
||||
consumeOutsideTap: widget.consumeOutsideTap,
|
||||
menuChildren: widget.children,
|
||||
builder: widget.builder,
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ImmichMenuScope extends InheritedWidget {
|
||||
final MenuController controller;
|
||||
|
||||
const _ImmichMenuScope({required this.controller, required super.child});
|
||||
|
||||
static MenuController? maybeOf(BuildContext context) =>
|
||||
context.dependOnInheritedWidgetOfExactType<_ImmichMenuScope>()?.controller;
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(_ImmichMenuScope oldWidget) => controller != oldWidget.controller;
|
||||
}
|
||||
|
||||
class ImmichMenuItem extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final FutureOr<void> Function() onPressed;
|
||||
final bool disabled;
|
||||
|
||||
const ImmichMenuItem({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.disabled = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ImmichMenuItem> createState() => _ImmichMenuItemState();
|
||||
}
|
||||
|
||||
class _ImmichMenuItemState extends State<ImmichMenuItem> {
|
||||
Future<void> _onPressed(MenuController? controller) async {
|
||||
try {
|
||||
await widget.onPressed();
|
||||
} finally {
|
||||
controller?.close();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = _ImmichMenuScope.maybeOf(context);
|
||||
return MenuItemButton(
|
||||
onPressed: widget.disabled ? null : () => _onPressed(controller),
|
||||
closeOnActivate: controller == null,
|
||||
style: MenuItemButton.styleFrom(
|
||||
foregroundColor: context.colorOverride,
|
||||
alignment: .centerLeft,
|
||||
padding: const .symmetric(horizontal: ImmichSpacing.lg, vertical: ImmichSpacing.md),
|
||||
),
|
||||
leadingIcon: Icon(widget.icon, size: ImmichIconSize.sm),
|
||||
child: Text(widget.label, style: const .new(fontSize: ImmichTextSize.body)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ class _ImmichPasswordInputState extends State<ImmichPasswordInput> {
|
||||
icon: Icon(_visible ? Icons.visibility_off_rounded : Icons.visibility_rounded),
|
||||
),
|
||||
autofillHints: [AutofillHints.password],
|
||||
keyboardType: TextInputType.text,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +1,85 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:immich_ui/src/constants.dart';
|
||||
import 'package:immich_ui/src/types.dart';
|
||||
|
||||
class ImmichTextButton extends StatefulWidget {
|
||||
class ImmichTextButton extends StatelessWidget {
|
||||
final String labelText;
|
||||
final IconData? icon;
|
||||
final FutureOr<void> Function() onPressed;
|
||||
final ImmichVariant variant;
|
||||
final ImmichColor color;
|
||||
final bool expanded;
|
||||
final bool loading;
|
||||
final bool disabled;
|
||||
final bool? loading;
|
||||
|
||||
const ImmichTextButton({
|
||||
super.key,
|
||||
required this.labelText,
|
||||
this.icon,
|
||||
required this.onPressed,
|
||||
this.variant = .filled,
|
||||
this.variant = ImmichVariant.filled,
|
||||
this.color = ImmichColor.primary,
|
||||
this.expanded = true,
|
||||
|
||||
this.loading = false,
|
||||
this.disabled = false,
|
||||
this.loading,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ImmichTextButton> createState() => _ImmichTextButtonState();
|
||||
}
|
||||
Widget _buildButton(ImmichVariant variant) {
|
||||
final Widget? effectiveIcon = loading
|
||||
? const SizedBox.square(
|
||||
dimension: ImmichIconSize.md,
|
||||
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.lg),
|
||||
)
|
||||
: icon != null
|
||||
? Icon(icon, fontWeight: FontWeight.w600)
|
||||
: null;
|
||||
final hasIcon = effectiveIcon != null;
|
||||
|
||||
class _ImmichTextButtonState extends State<ImmichTextButton> {
|
||||
bool _loading = false;
|
||||
bool get _isLoading => widget.loading ?? _loading;
|
||||
final label = Text(labelText, style: const TextStyle(fontSize: ImmichTextSize.body, fontWeight: FontWeight.bold));
|
||||
final style = ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: ImmichSpacing.md));
|
||||
|
||||
Future<void> _onPressed() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
await widget.onPressed();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
final effectiveOnPressed = disabled || loading ? null : onPressed;
|
||||
|
||||
switch (variant) {
|
||||
case ImmichVariant.filled:
|
||||
if (hasIcon) {
|
||||
return ElevatedButton.icon(
|
||||
style: style,
|
||||
onPressed: effectiveOnPressed,
|
||||
icon: effectiveIcon,
|
||||
label: label,
|
||||
);
|
||||
}
|
||||
|
||||
return ElevatedButton(
|
||||
style: style,
|
||||
onPressed: effectiveOnPressed,
|
||||
child: label,
|
||||
);
|
||||
case ImmichVariant.ghost:
|
||||
if (hasIcon) {
|
||||
return TextButton.icon(
|
||||
style: style,
|
||||
onPressed: effectiveOnPressed,
|
||||
icon: effectiveIcon,
|
||||
label: label,
|
||||
);
|
||||
}
|
||||
|
||||
return TextButton(
|
||||
style: style,
|
||||
onPressed: effectiveOnPressed,
|
||||
child: label,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Widget? icon = _isLoading
|
||||
? const SizedBox.square(
|
||||
dimension: ImmichIconSize.md,
|
||||
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.lg),
|
||||
)
|
||||
: widget.icon != null
|
||||
? Icon(widget.icon, fontWeight: .w600)
|
||||
: null;
|
||||
|
||||
final label = Text(
|
||||
widget.labelText,
|
||||
style: const .new(fontSize: ImmichTextSize.body, fontWeight: .bold),
|
||||
);
|
||||
final style = ElevatedButton.styleFrom(padding: const .symmetric(vertical: ImmichSpacing.md));
|
||||
final onPressed = widget.disabled || _isLoading ? null : _onPressed;
|
||||
|
||||
final button = switch (widget.variant) {
|
||||
ImmichVariant.filled => ElevatedButton.icon(style: style, onPressed: onPressed, icon: icon, label: label),
|
||||
ImmichVariant.ghost => TextButton.icon(style: style, onPressed: onPressed, icon: icon, label: label),
|
||||
};
|
||||
|
||||
if (widget.expanded) {
|
||||
final button = _buildButton(variant);
|
||||
if (expanded) {
|
||||
return SizedBox(width: double.infinity, child: button);
|
||||
}
|
||||
return button;
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/color_override.dart';
|
||||
import 'package:immich_ui/src/translation.dart';
|
||||
|
||||
extension TranslationHelper on BuildContext {
|
||||
ImmichTranslations get translations => ImmichTranslationProvider.of(this);
|
||||
}
|
||||
|
||||
extension ColorHelper on BuildContext {
|
||||
Color? get colorOverride => ImmichColorOverride.maybeOf(this);
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/components/column_button.dart';
|
||||
import 'package:immich_ui/src/previews.dart';
|
||||
|
||||
void _previewNoop() {}
|
||||
|
||||
@ImmichPreview(group: 'ColumnButton', name: 'Default')
|
||||
Widget previewColumnButtonDefault() => const Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichColumnButton(onPressed: _previewNoop, icon: Icons.favorite_border_rounded, label: 'Favorite'),
|
||||
ImmichColumnButton(onPressed: _previewNoop, icon: Icons.archive_outlined, label: 'Archive'),
|
||||
ImmichColumnButton(onPressed: _previewNoop, icon: Icons.delete_outline_rounded, label: 'Delete'),
|
||||
],
|
||||
);
|
||||
|
||||
@ImmichPreview(group: 'ColumnButton', name: 'Loading')
|
||||
Widget previewColumnButtonLoading() => ImmichColumnButton(
|
||||
onPressed: () => Future<void>.delayed(const .new(seconds: 2)),
|
||||
icon: Icons.download,
|
||||
label: 'Download',
|
||||
);
|
||||
|
||||
@ImmichPreview(group: 'ColumnButton', name: 'Disabled')
|
||||
Widget previewColumnButtonDisabled() =>
|
||||
const ImmichColumnButton(onPressed: _previewNoop, icon: Icons.ios_share_rounded, label: 'Share', disabled: true);
|
||||
@@ -1,19 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/components/menu_item.dart';
|
||||
import 'package:immich_ui/src/previews.dart';
|
||||
|
||||
void _previewNoop() {}
|
||||
|
||||
@ImmichPreview(group: 'MenuItem', name: 'Default')
|
||||
Widget previewMenuItemDefault() => const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ImmichMenuItem(onPressed: _previewNoop, icon: Icons.info_outline, label: 'Info'),
|
||||
ImmichMenuItem(onPressed: _previewNoop, icon: Icons.help_outline_rounded, label: 'Troubleshoot'),
|
||||
ImmichMenuItem(onPressed: _previewNoop, icon: Icons.cast_rounded, label: 'Cast'),
|
||||
],
|
||||
);
|
||||
|
||||
@ImmichPreview(group: 'MenuItem', name: 'Disabled')
|
||||
Widget previewMenuItemDisabled() =>
|
||||
const ImmichMenuItem(onPressed: _previewNoop, icon: Icons.delete_outline_rounded, label: 'Delete', disabled: true);
|
||||
@@ -1,32 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/constants.dart';
|
||||
import 'package:immich_ui/src/previews.dart';
|
||||
import 'package:immich_ui/src/snackbar.dart';
|
||||
|
||||
@ImmichPreview(group: 'Snackbar', name: 'Types')
|
||||
Widget previewSnackbarTypes() => const _SnackbarDemo();
|
||||
|
||||
class _SnackbarDemo extends StatelessWidget {
|
||||
const _SnackbarDemo();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ScaffoldMessenger(
|
||||
key: scaffoldMessengerKey,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: Center(
|
||||
child: Wrap(
|
||||
spacing: ImmichSpacing.md,
|
||||
runSpacing: ImmichSpacing.md,
|
||||
children: [
|
||||
ElevatedButton(onPressed: () => snackbar.info('Info message'), child: const Text('Info')),
|
||||
ElevatedButton(onPressed: () => snackbar.success('Saved'), child: const Text('Success')),
|
||||
ElevatedButton(onPressed: () => snackbar.error('Something failed'), child: const Text('Error')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,16 @@ Widget previewTextButtonVariants() => const Wrap(
|
||||
],
|
||||
);
|
||||
|
||||
@ImmichPreview(group: 'TextButton', name: 'Colors')
|
||||
Widget previewTextButtonColors() => const Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichTextButton(onPressed: _previewNoop, labelText: 'Primary', expanded: false),
|
||||
ImmichTextButton(onPressed: _previewNoop, labelText: 'Secondary', color: ImmichColor.secondary, expanded: false),
|
||||
],
|
||||
);
|
||||
|
||||
@ImmichPreview(group: 'TextButton', name: 'With Icons')
|
||||
Widget previewTextButtonWithIcons() => const Wrap(
|
||||
spacing: 12,
|
||||
@@ -32,11 +42,7 @@ Widget previewTextButtonWithIcons() => const Wrap(
|
||||
);
|
||||
|
||||
@ImmichPreview(group: 'TextButton', name: 'Loading')
|
||||
Widget previewTextButtonLoading() => ImmichTextButton(
|
||||
onPressed: () => Future<void>.delayed(const Duration(seconds: 2)),
|
||||
labelText: 'Click me',
|
||||
expanded: false,
|
||||
);
|
||||
Widget previewTextButtonLoading() => const _PreviewLoadingDemo();
|
||||
|
||||
@ImmichPreview(group: 'TextButton', name: 'Disabled')
|
||||
Widget previewTextButtonDisabled() => const Wrap(
|
||||
@@ -53,3 +59,30 @@ Widget previewTextButtonDisabled() => const Wrap(
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
class _PreviewLoadingDemo extends StatefulWidget {
|
||||
const _PreviewLoadingDemo();
|
||||
|
||||
@override
|
||||
State<_PreviewLoadingDemo> createState() => _PreviewLoadingDemoState();
|
||||
}
|
||||
|
||||
class _PreviewLoadingDemoState extends State<_PreviewLoadingDemo> {
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichTextButton(
|
||||
onPressed: () async {
|
||||
setState(() => _isLoading = true);
|
||||
await Future<void>.delayed(const Duration(seconds: 2));
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
},
|
||||
labelText: _isLoading ? 'Loading...' : 'Click Me',
|
||||
loading: _isLoading,
|
||||
expanded: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
|
||||
class SnackbarManager {
|
||||
const SnackbarManager();
|
||||
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? show(String message, SnackbarType type) {
|
||||
final messenger = scaffoldMessengerKey.currentState;
|
||||
final context = scaffoldMessengerKey.currentContext;
|
||||
if (messenger == null || context == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
messenger.hideCurrentSnackBar();
|
||||
return messenger.showSnackBar(_build(context, message, type));
|
||||
}
|
||||
|
||||
SnackBar _build(BuildContext context, String message, SnackbarType type) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.extension<ImmichColors>() ?? ImmichColors.harmonized(theme.colorScheme);
|
||||
final (IconData icon, Color background, Color foreground) = switch (type) {
|
||||
.info => (Icons.info_rounded, colors.info, colors.onInfo),
|
||||
.success => (Icons.check_circle_rounded, colors.success, colors.onSuccess),
|
||||
.error => (Icons.warning_rounded, colors.error, colors.onError),
|
||||
};
|
||||
|
||||
return SnackBar(
|
||||
behavior: .floating,
|
||||
backgroundColor: background,
|
||||
duration: const .new(seconds: 4),
|
||||
shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.sm))),
|
||||
content: Row(
|
||||
children: [
|
||||
Icon(icon, color: foreground, size: ImmichIconSize.sm),
|
||||
const SizedBox(width: ImmichSpacing.md),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
maxLines: 2,
|
||||
overflow: .ellipsis,
|
||||
style: .new(color: foreground, fontWeight: .w600, fontSize: ImmichTextSize.body),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? info(String message) => show(message, .info);
|
||||
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(String message) => show(message, .success);
|
||||
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(String message) => show(message, .error);
|
||||
}
|
||||
|
||||
const snackbar = SnackbarManager();
|
||||
@@ -1,8 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/constants.dart';
|
||||
import 'package:material_color_utilities/blend/blend.dart';
|
||||
import 'package:material_color_utilities/hct/hct.dart';
|
||||
import 'package:material_color_utilities/palettes/tonal_palette.dart';
|
||||
|
||||
class ImmichThemeProvider extends StatelessWidget {
|
||||
final ColorScheme colorScheme;
|
||||
@@ -14,7 +11,6 @@ class ImmichThemeProvider extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
extensions: [ImmichColors.harmonized(colorScheme)],
|
||||
colorScheme: colorScheme,
|
||||
brightness: colorScheme.brightness,
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
@@ -23,8 +19,8 @@ class ImmichThemeProvider extends StatelessWidget {
|
||||
final color = states.contains(WidgetState.error)
|
||||
? colorScheme.error
|
||||
: states.contains(WidgetState.focused)
|
||||
? colorScheme.primary
|
||||
: colorScheme.outline;
|
||||
? colorScheme.primary
|
||||
: colorScheme.outline;
|
||||
return OutlineInputBorder(
|
||||
borderSide: BorderSide(color: color),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)),
|
||||
@@ -42,71 +38,3 @@ class ImmichThemeProvider extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ImmichColors extends ThemeExtension<ImmichColors> {
|
||||
final Color info;
|
||||
final Color onInfo;
|
||||
final Color success;
|
||||
final Color onSuccess;
|
||||
final Color error;
|
||||
final Color onError;
|
||||
|
||||
const ImmichColors({
|
||||
required this.info,
|
||||
required this.onInfo,
|
||||
required this.success,
|
||||
required this.onSuccess,
|
||||
required this.error,
|
||||
required this.onError,
|
||||
});
|
||||
|
||||
factory ImmichColors.harmonized(ColorScheme scheme) {
|
||||
final (info, onInfo) = scheme.harmonized(const Color(0xFF1984E9));
|
||||
final (success, onSuccess) = scheme.harmonized(const Color(0xFF10C14D));
|
||||
final (error, onError) = scheme.harmonized(const Color(0xFFFA2921));
|
||||
return ImmichColors(
|
||||
info: info,
|
||||
onInfo: onInfo,
|
||||
success: success,
|
||||
onSuccess: onSuccess,
|
||||
error: error,
|
||||
onError: onError,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
ImmichColors copyWith({Color? info, Color? onInfo, Color? success, Color? onSuccess, Color? error, Color? onError}) {
|
||||
return ImmichColors(
|
||||
info: info ?? this.info,
|
||||
onInfo: onInfo ?? this.onInfo,
|
||||
success: success ?? this.success,
|
||||
onSuccess: onSuccess ?? this.onSuccess,
|
||||
error: error ?? this.error,
|
||||
onError: onError ?? this.onError,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
ImmichColors lerp(ImmichColors? other, double t) {
|
||||
if (other == null) {
|
||||
return this;
|
||||
}
|
||||
return ImmichColors(
|
||||
info: Color.lerp(info, other.info, t)!,
|
||||
onInfo: Color.lerp(onInfo, other.onInfo, t)!,
|
||||
success: Color.lerp(success, other.success, t)!,
|
||||
onSuccess: Color.lerp(onSuccess, other.onSuccess, t)!,
|
||||
error: Color.lerp(error, other.error, t)!,
|
||||
onError: Color.lerp(onError, other.onError, t)!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension on ColorScheme {
|
||||
(Color container, Color onContainer) harmonized(Color seed) {
|
||||
final hct = Hct.fromInt(Blend.harmonize(seed.toARGB32(), primary.toARGB32()));
|
||||
final tones = TonalPalette.of(hct.hue, hct.chroma);
|
||||
final isDark = brightness == Brightness.dark;
|
||||
return (Color(tones.get(isDark ? 30 : 90)), Color(tones.get(isDark ? 90 : 10)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
enum ImmichVariant { filled, ghost }
|
||||
enum ImmichVariant {
|
||||
filled,
|
||||
ghost,
|
||||
}
|
||||
|
||||
enum ImmichColor { primary, secondary }
|
||||
|
||||
enum SnackbarType { info, success, error }
|
||||
enum ImmichColor {
|
||||
primary,
|
||||
secondary,
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: "direct main"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
|
||||
@@ -7,7 +7,6 @@ environment:
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
material_color_utilities: any
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_ui/src/color_override.dart';
|
||||
import 'package:immich_ui/src/components/icon_button.dart';
|
||||
|
||||
import 'test_utils.dart';
|
||||
|
||||
void main() {
|
||||
group('ImmichColorOverride', () {
|
||||
testWidgets('exposes the override color to descendants', (tester) async {
|
||||
Color? captured;
|
||||
await tester.pumpTestWidget(
|
||||
ImmichColorOverride(
|
||||
color: Colors.green,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
captured = ImmichColorOverride.maybeOf(context);
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(captured, Colors.green);
|
||||
});
|
||||
|
||||
testWidgets('maybeOf returns null when there is no override', (tester) async {
|
||||
Color? captured = Colors.black;
|
||||
await tester.pumpTestWidget(
|
||||
Builder(
|
||||
builder: (context) {
|
||||
captured = ImmichColorOverride.maybeOf(context);
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(captured, isNull);
|
||||
});
|
||||
|
||||
testWidgets('a descendant component adopts the override as its foreground', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
ImmichColorOverride(
|
||||
color: Colors.green,
|
||||
child: ImmichIconButton(icon: Icons.add, onPressed: () {}),
|
||||
),
|
||||
);
|
||||
|
||||
final button = tester.widget<IconButton>(find.byType(IconButton));
|
||||
expect(button.style?.foregroundColor?.resolve(<WidgetState>{}), Colors.green);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_ui/src/snackbar.dart';
|
||||
|
||||
import 'test_utils.dart';
|
||||
|
||||
void main() {
|
||||
group('SnackbarManager', () {
|
||||
testWidgets('shows the message', (tester) async {
|
||||
await tester.pumpTestWidget(const SizedBox());
|
||||
|
||||
snackbar.success('hello');
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('hello'), findsOneWidget);
|
||||
expect(find.byType(SnackBar), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('replaces the current snackbar', (tester) async {
|
||||
await tester.pumpTestWidget(const SizedBox());
|
||||
|
||||
snackbar.info('first');
|
||||
await tester.pump();
|
||||
snackbar.error('second');
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('first'), findsNothing);
|
||||
expect(find.text('second'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('no-ops when the messenger is unmounted', (tester) async {
|
||||
expect(snackbar.show('x', .info), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_ui/src/snackbar.dart';
|
||||
|
||||
extension WidgetTesterExtension on WidgetTester {
|
||||
/// Pumps a widget wrapped in MaterialApp and Scaffold for testing.
|
||||
Future<void> pumpTestWidget(Widget widget) {
|
||||
return pumpWidget(
|
||||
MaterialApp(
|
||||
scaffoldMessengerKey: scaffoldMessengerKey,
|
||||
home: Scaffold(body: widget),
|
||||
),
|
||||
);
|
||||
return pumpWidget(MaterialApp(home: Scaffold(body: widget)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,12 @@ class PlatformAsset {
|
||||
|
||||
final PlatformAssetPlaybackStyle playbackStyle;
|
||||
|
||||
// iOS burst grouping. `burstId` = PHAsset.burstIdentifier (null for non-burst
|
||||
// assets). `isBurstRepresentative` = the auto-picked lead frame at detection
|
||||
// time. android always returns null/false (no burstIdentifier equivalent).
|
||||
final String? burstId;
|
||||
final bool isBurstRepresentative;
|
||||
|
||||
const PlatformAsset({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -50,6 +56,8 @@ class PlatformAsset {
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.playbackStyle = PlatformAssetPlaybackStyle.unknown,
|
||||
this.burstId,
|
||||
this.isBurstRepresentative = false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -103,6 +111,29 @@ class CloudIdResult {
|
||||
const CloudIdResult({required this.assetId, this.error, this.cloudId});
|
||||
}
|
||||
|
||||
class BaseResource {
|
||||
final String path;
|
||||
final String sha1;
|
||||
|
||||
const BaseResource({required this.path, required this.sha1});
|
||||
}
|
||||
|
||||
// The readable originals of an edited live photo: the still always, the paired
|
||||
// video when the asset still carries one. Both are temp copies the caller
|
||||
// uploads then deletes.
|
||||
class BaseLivePhoto {
|
||||
final BaseResource still;
|
||||
final BaseResource? video;
|
||||
|
||||
const BaseLivePhoto({required this.still, this.video});
|
||||
}
|
||||
|
||||
// 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()
|
||||
abstract class NativeSyncApi {
|
||||
@async
|
||||
@@ -143,4 +174,26 @@ abstract class NativeSyncApi {
|
||||
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds);
|
||||
|
||||
@async
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
BaseResource? getBaseResource(String assetId, {bool allowNetworkAccess = false});
|
||||
|
||||
/// Streams the bytes immich treats as the asset's canonical content — the same
|
||||
/// resource [hashAssets] hashes (`PHAsset.getResource()`, the `.isCurrent`
|
||||
/// rendition). Used to upload iOS burst members: they're invisible to
|
||||
/// photo_manager, so this is the only way to read their file, and streaming
|
||||
/// the same resource the hash measured keeps the server checksum aligned with
|
||||
/// the local one (else the asset shows cloud-only). iOS-only; android returns null.
|
||||
@async
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
BaseResource? getCurrentResource(String assetId, {bool allowNetworkAccess = false});
|
||||
|
||||
@async
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
EditState getEditState(String assetId, {bool allowNetworkAccess = false});
|
||||
|
||||
@async
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
BaseLivePhoto? getBaseLivePhoto(String assetId, {bool allowNetworkAccess = false});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||
import 'package:immich_mobile/repositories/partner_api.repository.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
@@ -7,3 +8,5 @@ class MockSyncApi extends Mock implements SyncApi {}
|
||||
class MockServerApi extends Mock implements ServerApi {}
|
||||
|
||||
class MockPartnerApiRepository extends Mock implements PartnerApiRepository {}
|
||||
|
||||
class MockConnectivityApi extends Mock implements ConnectivityApi {}
|
||||
|
||||
@@ -4,8 +4,12 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.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/domain/models/asset/base_asset.model.dart' as domain;
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -28,26 +32,68 @@ SyncAssetV1 _createAsset({
|
||||
String ownerId = 'user-1',
|
||||
int? width,
|
||||
int? height,
|
||||
AssetVisibility visibility = AssetVisibility.timeline,
|
||||
AssetTypeEnum type = AssetTypeEnum.IMAGE,
|
||||
String? livePhotoVideoId,
|
||||
String? stackId,
|
||||
bool isFavorite = false,
|
||||
DateTime? deletedAt,
|
||||
}) {
|
||||
return SyncAssetV1(
|
||||
id: id,
|
||||
checksum: checksum,
|
||||
originalFileName: fileName,
|
||||
type: AssetTypeEnum.IMAGE,
|
||||
type: type,
|
||||
ownerId: ownerId,
|
||||
isFavorite: false,
|
||||
isFavorite: isFavorite,
|
||||
fileCreatedAt: DateTime(2024, 1, 1),
|
||||
fileModifiedAt: DateTime(2024, 1, 1),
|
||||
createdAt: DateTime(2024, 1, 1),
|
||||
localDateTime: DateTime(2024, 1, 1),
|
||||
visibility: AssetVisibility.timeline,
|
||||
visibility: visibility,
|
||||
width: width,
|
||||
height: height,
|
||||
deletedAt: null,
|
||||
deletedAt: deletedAt,
|
||||
duration: null,
|
||||
libraryId: null,
|
||||
livePhotoVideoId: null,
|
||||
stackId: null,
|
||||
livePhotoVideoId: livePhotoVideoId,
|
||||
stackId: stackId,
|
||||
thumbhash: null,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
SyncAssetV2 _createAssetV2({
|
||||
required String id,
|
||||
required String checksum,
|
||||
required String fileName,
|
||||
String ownerId = 'user-1',
|
||||
AssetVisibility visibility = AssetVisibility.timeline,
|
||||
AssetTypeEnum type = AssetTypeEnum.IMAGE,
|
||||
String? livePhotoVideoId,
|
||||
String? stackId,
|
||||
bool isFavorite = false,
|
||||
DateTime? deletedAt,
|
||||
}) {
|
||||
return SyncAssetV2(
|
||||
id: id,
|
||||
checksum: checksum,
|
||||
originalFileName: fileName,
|
||||
type: type,
|
||||
ownerId: ownerId,
|
||||
isFavorite: isFavorite,
|
||||
fileCreatedAt: DateTime(2024, 1, 1),
|
||||
fileModifiedAt: DateTime(2024, 1, 1),
|
||||
createdAt: DateTime(2024, 1, 1),
|
||||
localDateTime: DateTime(2024, 1, 1),
|
||||
visibility: visibility,
|
||||
width: null,
|
||||
height: null,
|
||||
deletedAt: deletedAt,
|
||||
duration: null,
|
||||
libraryId: null,
|
||||
livePhotoVideoId: livePhotoVideoId,
|
||||
stackId: stackId,
|
||||
thumbhash: null,
|
||||
isEdited: false,
|
||||
);
|
||||
@@ -189,6 +235,168 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('SyncStreamRepository - websocket fast-path link state', () {
|
||||
Future<RemoteAssetEntityData> read(String id) =>
|
||||
(db.remoteAssetEntity.select()..where((t) => t.id.equals(id))).getSingle();
|
||||
|
||||
test('fromWebsocket does not clobber visibility the checkpoint sync already hid', () async {
|
||||
await sut.updateUsersV1([_createUser()]);
|
||||
const id = 'motion-video';
|
||||
|
||||
// checkpoint sync stored the real server state: a hidden motion video
|
||||
await sut.updateAssetsV1([
|
||||
_createAsset(
|
||||
id: id,
|
||||
checksum: 'cs',
|
||||
fileName: 'IMG.mov',
|
||||
type: AssetTypeEnum.VIDEO,
|
||||
visibility: AssetVisibility.hidden,
|
||||
),
|
||||
]);
|
||||
|
||||
// a stale upload-ready event arrives with the upload-time state (timeline)
|
||||
await sut.updateAssetsV1([
|
||||
_createAsset(
|
||||
id: id,
|
||||
checksum: 'cs',
|
||||
fileName: 'IMG.mov',
|
||||
type: AssetTypeEnum.VIDEO,
|
||||
visibility: AssetVisibility.timeline,
|
||||
),
|
||||
], fromWebsocket: true);
|
||||
|
||||
expect((await read(id)).visibility, domain.AssetVisibility.hidden);
|
||||
});
|
||||
|
||||
test('authoritative sync (default) still overwrites visibility', () async {
|
||||
await sut.updateUsersV1([_createUser()]);
|
||||
const id = 'asset-1';
|
||||
|
||||
await sut.updateAssetsV1([
|
||||
_createAsset(id: id, checksum: 'cs', fileName: 'IMG.heic', visibility: AssetVisibility.hidden),
|
||||
]);
|
||||
await sut.updateAssetsV1([
|
||||
_createAsset(id: id, checksum: 'cs', fileName: 'IMG.heic', visibility: AssetVisibility.timeline),
|
||||
]);
|
||||
|
||||
expect((await read(id)).visibility, domain.AssetVisibility.timeline);
|
||||
});
|
||||
|
||||
test('fromWebsocket still inserts a new asset with its payload visibility', () async {
|
||||
await sut.updateUsersV1([_createUser()]);
|
||||
const id = 'new-asset';
|
||||
|
||||
await sut.updateAssetsV1([
|
||||
_createAsset(id: id, checksum: 'cs', fileName: 'IMG.heic', visibility: AssetVisibility.timeline),
|
||||
], fromWebsocket: true);
|
||||
|
||||
expect((await read(id)).visibility, domain.AssetVisibility.timeline);
|
||||
});
|
||||
|
||||
test('fromWebsocket conflict keeps checkpoint livePhotoVideoId and stackId but applies other fields', () async {
|
||||
await sut.updateUsersV1([_createUser()]);
|
||||
const id = 'edited-still-1';
|
||||
const stackId = 'stack-001';
|
||||
|
||||
await db.stackEntity.insertOne(StackEntityCompanion.insert(id: stackId, ownerId: 'user-1', primaryAssetId: id));
|
||||
|
||||
// checkpoint linked the edited still to its base pair
|
||||
await sut.updateAssetsV1([
|
||||
_createAsset(id: id, checksum: 'cs', fileName: 'IMG.heic', livePhotoVideoId: 'live-vid-001', stackId: stackId),
|
||||
]);
|
||||
|
||||
// stale websocket snapshot from upload time: no links yet, but favorite since
|
||||
await sut.updateAssetsV1([
|
||||
_createAsset(id: id, checksum: 'cs', fileName: 'IMG_RENAMED.heic', isFavorite: true),
|
||||
], fromWebsocket: true);
|
||||
|
||||
final row = await read(id);
|
||||
expect(row.livePhotoVideoId, 'live-vid-001');
|
||||
expect(row.stackId, stackId);
|
||||
expect(row.name, 'IMG_RENAMED.heic', reason: 'non-link fields from the websocket payload must still apply');
|
||||
expect(row.isFavorite, isTrue);
|
||||
});
|
||||
|
||||
test('fromWebsocket conflict does not resurrect an asset the checkpoint trashed', () async {
|
||||
await sut.updateUsersV1([_createUser()]);
|
||||
const id = 'trashed-asset';
|
||||
|
||||
await sut.updateAssetsV1([
|
||||
_createAsset(id: id, checksum: 'cs', fileName: 'IMG.heic', deletedAt: DateTime(2024, 2, 1)),
|
||||
]);
|
||||
|
||||
// debounced upload-ready snapshot always carries deletedAt null
|
||||
await sut.updateAssetsV1([_createAsset(id: id, checksum: 'cs', fileName: 'IMG.heic')], fromWebsocket: true);
|
||||
|
||||
expect((await read(id)).deletedAt, isNotNull);
|
||||
});
|
||||
|
||||
test('authoritative sync (default) still overwrites livePhotoVideoId, stackId and deletedAt', () async {
|
||||
await sut.updateUsersV1([_createUser()]);
|
||||
const id = 'unstacked-asset';
|
||||
const stackId = 'stack-002';
|
||||
|
||||
await db.stackEntity.insertOne(StackEntityCompanion.insert(id: stackId, ownerId: 'user-1', primaryAssetId: id));
|
||||
|
||||
await sut.updateAssetsV1([
|
||||
_createAsset(
|
||||
id: id,
|
||||
checksum: 'cs',
|
||||
fileName: 'IMG.heic',
|
||||
livePhotoVideoId: 'live-vid-002',
|
||||
stackId: stackId,
|
||||
deletedAt: DateTime(2024, 2, 1),
|
||||
),
|
||||
]);
|
||||
|
||||
// server unstacked + restored the asset; checkpoint sync must win
|
||||
await sut.updateAssetsV1([_createAsset(id: id, checksum: 'cs', fileName: 'IMG.heic')]);
|
||||
|
||||
final row = await read(id);
|
||||
expect(row.livePhotoVideoId, isNull);
|
||||
expect(row.stackId, isNull);
|
||||
expect(row.deletedAt, isNull);
|
||||
});
|
||||
|
||||
test('fromWebsocket does not clobber visibility through updateAssetsV2', () async {
|
||||
await sut.updateUsersV1([_createUser()]);
|
||||
const id = 'motion-video-v2';
|
||||
|
||||
await sut.updateAssetsV2([
|
||||
_createAssetV2(
|
||||
id: id,
|
||||
checksum: 'cs',
|
||||
fileName: 'IMG.mov',
|
||||
type: AssetTypeEnum.VIDEO,
|
||||
visibility: AssetVisibility.hidden,
|
||||
),
|
||||
]);
|
||||
|
||||
await sut.updateAssetsV2([
|
||||
_createAssetV2(
|
||||
id: id,
|
||||
checksum: 'cs',
|
||||
fileName: 'IMG.mov',
|
||||
type: AssetTypeEnum.VIDEO,
|
||||
visibility: AssetVisibility.timeline,
|
||||
),
|
||||
], fromWebsocket: true);
|
||||
|
||||
expect((await read(id)).visibility, domain.AssetVisibility.hidden);
|
||||
});
|
||||
|
||||
test('fromWebsocket still inserts a new asset through updateAssetsV2', () async {
|
||||
await sut.updateUsersV1([_createUser()]);
|
||||
const id = 'new-asset-v2';
|
||||
|
||||
await sut.updateAssetsV2([
|
||||
_createAssetV2(id: id, checksum: 'cs', fileName: 'IMG.heic', visibility: AssetVisibility.timeline),
|
||||
], fromWebsocket: true);
|
||||
|
||||
expect((await read(id)).visibility, domain.AssetVisibility.timeline);
|
||||
});
|
||||
});
|
||||
|
||||
group('SyncStreamRepository - reset()', () {
|
||||
test('nulls linkedRemoteAlbumId on localAlbumEntity so FK refs do not dangle', () async {
|
||||
const localAlbumId = 'local-1';
|
||||
@@ -239,5 +447,32 @@ void main() {
|
||||
expect(after.name, equals('Camera'));
|
||||
expect(after.backupSelection, equals(BackupSelection.none));
|
||||
});
|
||||
|
||||
test('nulls priorRemoteId and syncedChecksum on localAssetEntity but keeps the row', () async {
|
||||
const localId = 'local-edited';
|
||||
|
||||
await db.localAssetEntity.insertOne(
|
||||
LocalAssetEntityCompanion.insert(
|
||||
id: localId,
|
||||
name: 'IMG.heic',
|
||||
type: domain.AssetType.image,
|
||||
checksum: const drift.Value('cs-local'),
|
||||
priorRemoteId: const drift.Value('prior-remote-1'),
|
||||
syncedChecksum: const drift.Value('cs-synced'),
|
||||
),
|
||||
);
|
||||
|
||||
await sut.reset();
|
||||
|
||||
final after = await (db.localAssetEntity.select()..where((t) => t.id.equals(localId))).getSingle();
|
||||
expect(
|
||||
after.priorRemoteId,
|
||||
isNull,
|
||||
reason: 'the remote rows the stamps point at were wiped — a later backup must not stack onto dead ids',
|
||||
);
|
||||
expect(after.syncedChecksum, isNull);
|
||||
expect(after.name, equals('IMG.heic'), reason: 'local asset row itself must be preserved');
|
||||
expect(after.checksum, equals('cs-local'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
|
||||
import 'package:immich_mobile/domain/services/partner.service.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
||||
@@ -14,3 +15,5 @@ class MockNativeSyncApi extends Mock implements NativeSyncApi {}
|
||||
class MockAppSettingsService extends Mock implements AppSettingsService {}
|
||||
|
||||
class MockPartnerService extends Mock implements PartnerService {}
|
||||
|
||||
class MockEditRevertService extends Mock implements EditRevertService {}
|
||||
|
||||
@@ -133,6 +133,7 @@ void main() {
|
||||
orientation: 0,
|
||||
isFavorite: false,
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image,
|
||||
isBurstRepresentative: false,
|
||||
);
|
||||
|
||||
final assetsToRestore = [LocalAssetStub.image1];
|
||||
@@ -232,6 +233,7 @@ void main() {
|
||||
createdAt: 1700000000,
|
||||
updatedAt: 1732000000,
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image,
|
||||
isBurstRepresentative: false,
|
||||
);
|
||||
|
||||
final localAsset = platformAsset.toLocalAsset();
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' hide AssetVisibility;
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
@@ -36,7 +37,6 @@ class _AbortCallbackWrapper {
|
||||
|
||||
class _MockAbortCallbackWrapper extends Mock implements _AbortCallbackWrapper {}
|
||||
|
||||
|
||||
void main() {
|
||||
late SyncStreamService sut;
|
||||
late SyncStreamRepository mockSyncStreamRepo;
|
||||
@@ -620,4 +620,174 @@ void main() {
|
||||
verifyNever(() => mockSyncMigrationRepo.v20260128CopyExifWidthHeightToAsset());
|
||||
});
|
||||
});
|
||||
|
||||
group('SyncStreamService - websocket fromWebsocket plumbing', () {
|
||||
SyncAssetV1 wsAssetV1(String id) => SyncAssetV1(
|
||||
checksum: 'checksum-$id',
|
||||
createdAt: DateTime(2025, 1, 2),
|
||||
deletedAt: null,
|
||||
duration: null,
|
||||
fileCreatedAt: DateTime(2025),
|
||||
fileModifiedAt: DateTime(2025, 1, 2),
|
||||
height: null,
|
||||
id: id,
|
||||
isEdited: false,
|
||||
isFavorite: false,
|
||||
libraryId: null,
|
||||
livePhotoVideoId: null,
|
||||
localDateTime: DateTime(2025, 1, 3),
|
||||
originalFileName: '$id.jpg',
|
||||
ownerId: 'owner',
|
||||
stackId: null,
|
||||
thumbhash: null,
|
||||
type: AssetTypeEnum.IMAGE,
|
||||
visibility: AssetVisibility.timeline,
|
||||
width: null,
|
||||
);
|
||||
|
||||
SyncAssetV2 wsAssetV2(String id) => SyncAssetV2(
|
||||
checksum: 'checksum-$id',
|
||||
createdAt: DateTime(2025, 1, 2),
|
||||
deletedAt: null,
|
||||
duration: null,
|
||||
fileCreatedAt: DateTime(2025),
|
||||
fileModifiedAt: DateTime(2025, 1, 2),
|
||||
height: null,
|
||||
id: id,
|
||||
isEdited: false,
|
||||
isFavorite: false,
|
||||
libraryId: null,
|
||||
livePhotoVideoId: null,
|
||||
localDateTime: DateTime(2025, 1, 3),
|
||||
originalFileName: '$id.jpg',
|
||||
ownerId: 'owner',
|
||||
stackId: null,
|
||||
thumbhash: null,
|
||||
type: AssetTypeEnum.IMAGE,
|
||||
visibility: AssetVisibility.timeline,
|
||||
width: null,
|
||||
);
|
||||
|
||||
SyncAssetExifV1 wsExif(String id) => SyncAssetExifV1(
|
||||
assetId: id,
|
||||
city: null,
|
||||
country: null,
|
||||
dateTimeOriginal: null,
|
||||
description: null,
|
||||
exifImageHeight: null,
|
||||
exifImageWidth: null,
|
||||
exposureTime: null,
|
||||
fNumber: null,
|
||||
fileSizeInByte: null,
|
||||
focalLength: null,
|
||||
fps: null,
|
||||
iso: null,
|
||||
latitude: null,
|
||||
lensModel: null,
|
||||
longitude: null,
|
||||
make: null,
|
||||
model: null,
|
||||
modifyDate: null,
|
||||
orientation: null,
|
||||
profileDescription: null,
|
||||
projectionType: null,
|
||||
rating: null,
|
||||
state: null,
|
||||
timeZone: null,
|
||||
);
|
||||
|
||||
// toJson keeps enums as objects; round-trip so fromJson sees plain JSON
|
||||
Map<String, dynamic> wsPayload(Map<String, dynamic> payload) =>
|
||||
jsonDecode(jsonEncode(payload)) as Map<String, dynamic>;
|
||||
|
||||
setUp(() {
|
||||
// stubs registered without fromWebsocket won't match calls that pass it
|
||||
when(
|
||||
() => mockSyncStreamRepo.updateAssetsV1(
|
||||
any(),
|
||||
debugLabel: any(named: 'debugLabel'),
|
||||
fromWebsocket: any(named: 'fromWebsocket'),
|
||||
),
|
||||
).thenAnswer(successHandler);
|
||||
when(
|
||||
() => mockSyncStreamRepo.updateAssetsV2(
|
||||
any(),
|
||||
debugLabel: any(named: 'debugLabel'),
|
||||
fromWebsocket: any(named: 'fromWebsocket'),
|
||||
),
|
||||
).thenAnswer(successHandler);
|
||||
when(
|
||||
() => mockSyncStreamRepo.replaceAssetEditsV1(any(), any(), debugLabel: any(named: 'debugLabel')),
|
||||
).thenAnswer(successHandler);
|
||||
});
|
||||
|
||||
test('handleWsAssetUploadReadyV1Batch passes fromWebsocket true', () async {
|
||||
await sut.handleWsAssetUploadReadyV1Batch([
|
||||
wsPayload({'asset': wsAssetV1('ws-v1').toJson(), 'exif': wsExif('ws-v1').toJson()}),
|
||||
]);
|
||||
|
||||
verify(
|
||||
() => mockSyncStreamRepo.updateAssetsV1(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: true),
|
||||
).called(1);
|
||||
verify(() => mockSyncStreamRepo.updateAssetsExifV1(any(), debugLabel: any(named: 'debugLabel'))).called(1);
|
||||
verifyNever(
|
||||
() => mockSyncStreamRepo.updateAssetsV1(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: false),
|
||||
);
|
||||
});
|
||||
|
||||
test('handleWsAssetUploadReadyV2Batch passes fromWebsocket true', () async {
|
||||
await sut.handleWsAssetUploadReadyV2Batch([
|
||||
wsPayload({'asset': wsAssetV2('ws-v2').toJson(), 'exif': wsExif('ws-v2').toJson()}),
|
||||
]);
|
||||
|
||||
verify(
|
||||
() => mockSyncStreamRepo.updateAssetsV2(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: true),
|
||||
).called(1);
|
||||
verify(() => mockSyncStreamRepo.updateAssetsExifV1(any(), debugLabel: any(named: 'debugLabel'))).called(1);
|
||||
verifyNever(
|
||||
() => mockSyncStreamRepo.updateAssetsV2(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: false),
|
||||
);
|
||||
});
|
||||
|
||||
test('handleWsAssetEditReadyV1 passes fromWebsocket true', () async {
|
||||
await sut.handleWsAssetEditReadyV1(wsPayload({'asset': wsAssetV1('ws-edit-v1').toJson(), 'edit': []}));
|
||||
|
||||
verify(
|
||||
() => mockSyncStreamRepo.updateAssetsV1(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: true),
|
||||
).called(1);
|
||||
verify(
|
||||
() => mockSyncStreamRepo.replaceAssetEditsV1('ws-edit-v1', any(), debugLabel: any(named: 'debugLabel')),
|
||||
).called(1);
|
||||
verifyNever(
|
||||
() => mockSyncStreamRepo.updateAssetsV1(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: false),
|
||||
);
|
||||
});
|
||||
|
||||
test('handleWsAssetEditReadyV2 passes fromWebsocket true', () async {
|
||||
await sut.handleWsAssetEditReadyV2(wsPayload({'asset': wsAssetV2('ws-edit-v2').toJson(), 'edit': []}));
|
||||
|
||||
verify(
|
||||
() => mockSyncStreamRepo.updateAssetsV2(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: true),
|
||||
).called(1);
|
||||
verify(
|
||||
() => mockSyncStreamRepo.replaceAssetEditsV1('ws-edit-v2', any(), debugLabel: any(named: 'debugLabel')),
|
||||
).called(1);
|
||||
verifyNever(
|
||||
() => mockSyncStreamRepo.updateAssetsV2(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: false),
|
||||
);
|
||||
});
|
||||
|
||||
test('checkpoint sync keeps fromWebsocket false', () async {
|
||||
await simulateEvents([
|
||||
SyncStreamStub.assetModified(id: 'remote-checkpoint', checksum: 'checksum-cp', ack: 'cp-ack'),
|
||||
]);
|
||||
|
||||
verify(
|
||||
() => mockSyncStreamRepo.updateAssetsV1(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: false),
|
||||
).called(1);
|
||||
verifyNever(
|
||||
() => mockSyncStreamRepo.updateAssetsV1(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: true),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+4
@@ -34,6 +34,7 @@ import 'schema_v27.dart' as v27;
|
||||
import 'schema_v28.dart' as v28;
|
||||
import 'schema_v29.dart' as v29;
|
||||
import 'schema_v30.dart' as v30;
|
||||
import 'schema_v31.dart' as v31;
|
||||
|
||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
@override
|
||||
@@ -99,6 +100,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
return v29.DatabaseAtV29(db);
|
||||
case 30:
|
||||
return v30.DatabaseAtV30(db);
|
||||
case 31:
|
||||
return v31.DatabaseAtV31(db);
|
||||
default:
|
||||
throw MissingSchemaException(version, versions);
|
||||
}
|
||||
@@ -135,5 +138,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
28,
|
||||
29,
|
||||
30,
|
||||
31,
|
||||
];
|
||||
}
|
||||
|
||||
+10201
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,17 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/native.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/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||
|
||||
void main() {
|
||||
late Drift db;
|
||||
@@ -18,13 +24,64 @@ void main() {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
Future<void> insertUser(String id) =>
|
||||
db.into(db.userEntity).insert(UserEntityCompanion.insert(id: id, email: '$id@test.dev', name: id));
|
||||
|
||||
Future<void> insertRemote(
|
||||
String id,
|
||||
String ownerId, {
|
||||
required String checksum,
|
||||
required DateTime at,
|
||||
DateTime? deletedAt,
|
||||
}) => db
|
||||
.into(db.remoteAssetEntity)
|
||||
.insert(
|
||||
RemoteAssetEntityCompanion.insert(
|
||||
id: id,
|
||||
name: '$id.jpg',
|
||||
type: AssetType.image,
|
||||
checksum: checksum,
|
||||
ownerId: ownerId,
|
||||
visibility: AssetVisibility.timeline,
|
||||
createdAt: Value(at),
|
||||
updatedAt: Value(at),
|
||||
uploadedAt: Value(at),
|
||||
deletedAt: Value(deletedAt),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> insertLocal(String id, {required DateTime at, String? checksum, String? priorRemoteId}) => db
|
||||
.into(db.localAssetEntity)
|
||||
.insert(
|
||||
LocalAssetEntityCompanion.insert(
|
||||
id: id,
|
||||
name: '$id.jpg',
|
||||
type: AssetType.image,
|
||||
checksum: Value(checksum),
|
||||
priorRemoteId: Value(priorRemoteId),
|
||||
createdAt: Value(at),
|
||||
updatedAt: Value(at),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> insertSelectedAlbumWith(String albumId, List<String> assetIds) async {
|
||||
await db
|
||||
.into(db.localAlbumEntity)
|
||||
.insert(
|
||||
LocalAlbumEntityCompanion.insert(id: albumId, name: albumId, backupSelection: BackupSelection.selected),
|
||||
);
|
||||
for (final assetId in assetIds) {
|
||||
await db
|
||||
.into(db.localAlbumAssetEntity)
|
||||
.insert(LocalAlbumAssetEntityCompanion.insert(assetId: assetId, albumId: albumId));
|
||||
}
|
||||
}
|
||||
|
||||
test('mergedBucket falls back to createdAt when localDateTime is null', () async {
|
||||
const userId = 'user-1';
|
||||
final createdAt = DateTime(2024, 1, 1, 12);
|
||||
|
||||
await db
|
||||
.into(db.userEntity)
|
||||
.insert(UserEntityCompanion.insert(id: userId, email: 'user-1@test.dev', name: 'User 1'));
|
||||
await insertUser(userId);
|
||||
|
||||
await db
|
||||
.into(db.remoteAssetEntity)
|
||||
@@ -49,4 +106,150 @@ void main() {
|
||||
expect(buckets.single.assetCount, 1);
|
||||
expect(buckets.single.bucketDate, isNotEmpty);
|
||||
});
|
||||
|
||||
// Reproduces the on-server shape of an edited live photo: 2 stills stacked
|
||||
// (primary = the edit) + 2 hidden motion videos. The hidden videos must never
|
||||
// become timeline tiles, and the stack collapses to its primary still.
|
||||
test('edited live photo: hidden motion videos excluded, stack collapses to its primary', () async {
|
||||
const userId = 'user-1';
|
||||
final t = DateTime(2026, 6, 8, 9);
|
||||
await insertUser(userId);
|
||||
await db
|
||||
.into(db.stackEntity)
|
||||
.insert(StackEntityCompanion.insert(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit-still'));
|
||||
|
||||
Future<void> ins(String id, AssetType type, AssetVisibility vis, {String? lpv, String? stackId}) => db
|
||||
.into(db.remoteAssetEntity)
|
||||
.insert(
|
||||
RemoteAssetEntityCompanion.insert(
|
||||
id: id,
|
||||
name: '$id.x',
|
||||
type: type,
|
||||
checksum: 'cs-$id',
|
||||
ownerId: userId,
|
||||
visibility: vis,
|
||||
createdAt: Value(t),
|
||||
updatedAt: Value(t),
|
||||
uploadedAt: Value(t),
|
||||
livePhotoVideoId: Value(lpv),
|
||||
stackId: Value(stackId),
|
||||
),
|
||||
);
|
||||
|
||||
await ins('orig-still', AssetType.image, AssetVisibility.timeline, lpv: 'orig-video', stackId: 'stack-1');
|
||||
await ins('edit-still', AssetType.image, AssetVisibility.timeline, lpv: 'edit-video', stackId: 'stack-1');
|
||||
await ins('orig-video', AssetType.video, AssetVisibility.hidden);
|
||||
await ins('edit-video', AssetType.video, AssetVisibility.hidden);
|
||||
|
||||
final rows = await db.mergedAssetDrift.mergedAsset(userIds: [userId], limit: (_) => Limit(1000, 0)).get();
|
||||
final ids = rows.map((r) => r.remoteId).toList();
|
||||
|
||||
expect(ids, isNot(contains('edit-video')), reason: 'hidden edit motion video must not be a timeline tile');
|
||||
expect(ids, isNot(contains('orig-video')), reason: 'hidden orig motion video must not be a timeline tile');
|
||||
expect(ids, isNot(contains('orig-still')), reason: 'non-primary stack member collapses behind the primary');
|
||||
expect(ids, ['edit-still'], reason: 'the stack shows exactly once, as its primary');
|
||||
});
|
||||
|
||||
test('local tile hidden when prior_remote_id points at a live remote', () async {
|
||||
const userId = 'user-1';
|
||||
final t = DateTime(2026, 6, 8, 9);
|
||||
await insertUser(userId);
|
||||
await insertRemote('live-remote', userId, checksum: 'cs-server', at: t);
|
||||
// re-encoded bytes: checksum no longer matches the remote, but prior does
|
||||
await insertLocal('hidden-local', at: t, checksum: 'cs-rerendered', priorRemoteId: 'live-remote');
|
||||
await insertLocal('plain-local', at: t);
|
||||
await insertSelectedAlbumWith('album-1', ['hidden-local', 'plain-local']);
|
||||
|
||||
final rows = await db.mergedAssetDrift.mergedAsset(userIds: [userId], limit: (_) => Limit(1000, 0)).get();
|
||||
final localOnlyIds = rows.where((r) => r.remoteId == null).map((r) => r.localId).toList();
|
||||
|
||||
expect(localOnlyIds, isNot(contains('hidden-local')), reason: 'local already live on server must not get a tile');
|
||||
expect(localOnlyIds, contains('plain-local'));
|
||||
expect(rows, hasLength(2));
|
||||
|
||||
final buckets = await db.mergedAssetDrift.mergedBucket(groupBy: GroupAssetsBy.day.index, userIds: [userId]).get();
|
||||
final bucketTotal = buckets.fold<int>(0, (sum, b) => sum + b.assetCount);
|
||||
expect(bucketTotal, rows.length, reason: 'bucket counts must match the visible tiles');
|
||||
});
|
||||
|
||||
test('local tile hidden when the prior remote is trashed', () async {
|
||||
const userId = 'user-1';
|
||||
final t = DateTime(2026, 6, 8, 9);
|
||||
await insertUser(userId);
|
||||
await insertRemote('trashed-remote', userId, checksum: 'cs-server', at: t, deletedAt: t);
|
||||
await insertLocal('local-1', at: t, checksum: 'cs-rerendered', priorRemoteId: 'trashed-remote');
|
||||
await insertSelectedAlbumWith('album-1', ['local-1']);
|
||||
|
||||
final rows = await db.mergedAssetDrift.mergedAsset(userIds: [userId], limit: (_) => Limit(1000, 0)).get();
|
||||
expect(rows, isEmpty, reason: 'trashing on the server must not pop the photo back onto the local timeline');
|
||||
|
||||
final buckets = await db.mergedAssetDrift.mergedBucket(groupBy: GroupAssetsBy.day.index, userIds: [userId]).get();
|
||||
expect(buckets, isEmpty);
|
||||
});
|
||||
|
||||
test('local tile shows again when the prior remote row is gone', () async {
|
||||
const userId = 'user-1';
|
||||
final t = DateTime(2026, 6, 8, 9);
|
||||
await insertUser(userId);
|
||||
// hard delete: sync removed the remote row entirely, only that re-opens the local
|
||||
await insertLocal('local-1', at: t, checksum: 'cs-rerendered', priorRemoteId: 'gone-remote');
|
||||
await insertSelectedAlbumWith('album-1', ['local-1']);
|
||||
|
||||
final rows = await db.mergedAssetDrift.mergedAsset(userIds: [userId], limit: (_) => Limit(1000, 0)).get();
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.single.remoteId, null);
|
||||
expect(rows.single.localId, 'local-1');
|
||||
|
||||
final buckets = await db.mergedAssetDrift.mergedBucket(groupBy: GroupAssetsBy.day.index, userIds: [userId]).get();
|
||||
expect(buckets.fold<int>(0, (sum, b) => sum + b.assetCount), 1);
|
||||
});
|
||||
|
||||
test('remote row falls back to prior_remote_id for local_id and local_checksum', () async {
|
||||
const userId = 'user-1';
|
||||
final t = DateTime(2026, 6, 8, 9);
|
||||
await insertUser(userId);
|
||||
await insertRemote('remote-1', userId, checksum: 'cs-server', at: t);
|
||||
await insertLocal('local-1', at: t, checksum: 'cs-on-device', priorRemoteId: 'remote-1');
|
||||
|
||||
final rows = await db.mergedAssetDrift.mergedAsset(userIds: [userId], limit: (_) => Limit(1000, 0)).get();
|
||||
final row = rows.single;
|
||||
|
||||
expect(row.remoteId, 'remote-1');
|
||||
expect(row.localId, 'local-1');
|
||||
expect(row.localChecksum, 'cs-on-device', reason: 'local render key must be the on-device bytes');
|
||||
expect(row.checksum, 'cs-server');
|
||||
});
|
||||
|
||||
test('checksum match links local_id and local_checksum', () async {
|
||||
const userId = 'user-1';
|
||||
final t = DateTime(2026, 6, 8, 9);
|
||||
await insertUser(userId);
|
||||
await insertRemote('remote-1', userId, checksum: 'cs-same', at: t);
|
||||
await insertLocal('local-1', at: t, checksum: 'cs-same');
|
||||
|
||||
final rows = await db.mergedAssetDrift.mergedAsset(userIds: [userId], limit: (_) => Limit(1000, 0)).get();
|
||||
final row = rows.single;
|
||||
|
||||
expect(row.remoteId, 'remote-1');
|
||||
expect(row.localId, 'local-1');
|
||||
expect(row.localChecksum, 'cs-same');
|
||||
expect(row.localChecksum, row.checksum);
|
||||
});
|
||||
|
||||
test('timeline repository maps local_checksum into RemoteAsset.localChecksum', () async {
|
||||
const userId = 'user-1';
|
||||
final t = DateTime(2026, 6, 8, 9);
|
||||
await insertUser(userId);
|
||||
await insertRemote('remote-match', userId, checksum: 'cs-same', at: t);
|
||||
await insertLocal('local-match', at: t, checksum: 'cs-same');
|
||||
await insertRemote('remote-prior', userId, checksum: 'cs-server', at: t);
|
||||
await insertLocal('local-prior', at: t, checksum: 'cs-on-device', priorRemoteId: 'remote-prior');
|
||||
|
||||
final assets = await DriftTimelineRepository(db).main([userId], GroupAssetsBy.day).assetSource(0, 100);
|
||||
final byId = {for (final a in assets.whereType<RemoteAsset>()) a.id: a};
|
||||
|
||||
expect(byId, hasLength(2));
|
||||
expect(byId['remote-match']?.localChecksum, 'cs-same');
|
||||
expect(byId['remote-prior']?.localChecksum, 'cs-on-device', reason: 'prior-linked local with re-encoded bytes');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:immich_mobile/infrastructure/repositories/partner.repository.dar
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.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/stack.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/sync_api.repository.dart';
|
||||
@@ -38,6 +39,8 @@ class MockRemoteAssetRepository extends Mock implements RemoteAssetRepository {}
|
||||
|
||||
class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {}
|
||||
|
||||
class MockDriftStackRepository extends Mock implements DriftStackRepository {}
|
||||
|
||||
class MockStorageRepository extends Mock implements StorageRepository {}
|
||||
|
||||
class MockDriftBackupRepository extends Mock implements DriftBackupRepository {}
|
||||
|
||||
@@ -135,6 +135,101 @@ void main() {
|
||||
expect(result.remainder, 2); // local2 + local3
|
||||
expect(result.processing, 1); // local3
|
||||
});
|
||||
|
||||
test('reconciled asset with live prior remote counts in total but not remainder', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
// uploaded as edit pair: prior remote is live, but no remote row matches the local checksum
|
||||
final prior = await ctx.newRemoteAsset(ownerId: userId);
|
||||
final local = await ctx.newLocalAsset(checksum: 'edited-1', syncedChecksum: 'edited-1', priorRemoteId: prior.id);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
|
||||
final result = await sut.getAllCounts(userId);
|
||||
expect(result.total, 1);
|
||||
expect(result.remainder, 0);
|
||||
expect(result.processing, 0);
|
||||
});
|
||||
|
||||
test('reverted-handled asset counts in total but not remainder', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
// revert handled: local re-hashed fresh, stamped synced + prior pointing at the base remote
|
||||
final prior = await ctx.newRemoteAsset(ownerId: userId);
|
||||
final local = await ctx.newLocalAsset(
|
||||
checksum: 'reverted-1',
|
||||
syncedChecksum: 'reverted-1',
|
||||
priorRemoteId: prior.id,
|
||||
);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
|
||||
final result = await sut.getAllCounts(userId);
|
||||
expect(result.total, 1);
|
||||
expect(result.remainder, 0);
|
||||
});
|
||||
|
||||
test('reconciled asset with trashed prior remote stays out of remainder', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
// prior was trashed, not hard-deleted: the row still exists, so the revert
|
||||
// stays handled — only a missing row re-opens the asset
|
||||
final prior = await ctx.newRemoteAsset(ownerId: userId, deletedAt: DateTime(2025, 6));
|
||||
final local = await ctx.newLocalAsset(checksum: 'edited-3', syncedChecksum: 'edited-3', priorRemoteId: prior.id);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
|
||||
final result = await sut.getAllCounts(userId);
|
||||
expect(result.total, 1);
|
||||
expect(result.remainder, 0);
|
||||
});
|
||||
|
||||
test('reconciled asset with hard-deleted prior remote counts in remainder', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
// prior remote row is gone -> needs re-upload
|
||||
final local = await ctx.newLocalAsset(checksum: 'edited-2', syncedChecksum: 'edited-2', priorRemoteId: 'gone');
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
|
||||
final result = await sut.getAllCounts(userId);
|
||||
expect(result.total, 1);
|
||||
expect(result.remainder, 1);
|
||||
});
|
||||
|
||||
test('burst members inherit their representative\'s selected album in the counts', () async {
|
||||
// getAllCounts drives the UI count + the foreground loop early-exit, so it
|
||||
// must agree with getCandidates: hidden members count when the cover's
|
||||
// album is selected, even though only the cover is album-tied.
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final rep = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true);
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: rep.id);
|
||||
|
||||
final result = await sut.getAllCounts(userId);
|
||||
expect(result.total, 3);
|
||||
expect(result.remainder, 3);
|
||||
expect(result.processing, 0);
|
||||
});
|
||||
|
||||
test('burst members are not counted when their representative album is not selected', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.none);
|
||||
final rep = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true);
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: rep.id);
|
||||
|
||||
final result = await sut.getAllCounts(userId);
|
||||
expect(result.total, 0);
|
||||
expect(result.remainder, 0);
|
||||
});
|
||||
|
||||
test('burst members are not counted when the rep is in both a selected and an excluded album', () async {
|
||||
// exclude-wins must propagate to the hidden members: the rep is suppressed,
|
||||
// so its members must not leak into the counts either.
|
||||
final selected = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final excluded = await ctx.newLocalAlbum(backupSelection: BackupSelection.excluded);
|
||||
final rep = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true);
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
await ctx.newLocalAlbumAsset(albumId: selected.id, assetId: rep.id);
|
||||
await ctx.newLocalAlbumAsset(albumId: excluded.id, assetId: rep.id);
|
||||
|
||||
final result = await sut.getAllCounts(userId);
|
||||
expect(result.total, 0);
|
||||
expect(result.remainder, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('getCandidates', () {
|
||||
@@ -164,6 +259,58 @@ void main() {
|
||||
expect(result.first.id, asset.id);
|
||||
});
|
||||
|
||||
test('burst member inherits candidacy from its representative in a selected album', () async {
|
||||
// iOS adds only the burst cover to a user album; the hidden members live in
|
||||
// the smart album. They must still back up when the cover's album is selected.
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final rep = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true);
|
||||
final member1 = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
final member2 = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
// only the representative is an album member
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: rep.id);
|
||||
|
||||
final result = await sut.getCandidates(userId);
|
||||
expect(result.map((a) => a.id).toSet(), {rep.id, member1.id, member2.id});
|
||||
});
|
||||
|
||||
test('burst member is NOT a candidate when its representative album is not selected', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.none);
|
||||
final rep = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true);
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: rep.id);
|
||||
|
||||
expect(await sut.getCandidates(userId), isEmpty);
|
||||
});
|
||||
|
||||
test('burstId filter returns only that burst\'s non-representative members', () async {
|
||||
// The bg re-enqueue path calls getCandidates(userId, burstId: b) to grab just
|
||||
// one burst's gated frames once its anchor lands — not the rep, not other bursts.
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final rep1 = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true);
|
||||
final m1a = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
final m1b = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
final rep2 = await ctx.newLocalAsset(burstId: 'b2', isBurstRepresentative: true);
|
||||
await ctx.newLocalAsset(burstId: 'b2', isBurstRepresentative: false);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: rep1.id);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: rep2.id);
|
||||
|
||||
final result = await sut.getCandidates(userId, burstId: 'b1');
|
||||
expect(result.map((a) => a.id).toSet(), {m1a.id, m1b.id});
|
||||
});
|
||||
|
||||
test('burst member is NOT a candidate when the rep is in both a selected and an excluded album', () async {
|
||||
// exclude-wins propagates: the rep is held back, so its hidden members must
|
||||
// not upload either.
|
||||
final selected = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final excluded = await ctx.newLocalAlbum(backupSelection: BackupSelection.excluded);
|
||||
final rep = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true);
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
await ctx.newLocalAlbumAsset(albumId: selected.id, assetId: rep.id);
|
||||
await ctx.newLocalAlbumAsset(albumId: excluded.id, assetId: rep.id);
|
||||
|
||||
expect(await sut.getCandidates(userId), isEmpty);
|
||||
});
|
||||
|
||||
test('excludes asset already backed up for the same user', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId);
|
||||
@@ -240,5 +387,64 @@ void main() {
|
||||
expect(result.length, 1);
|
||||
expect(result.first.id, asset.id);
|
||||
});
|
||||
|
||||
test('includes re-edited asset whose synced checksum is stale', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final prior = await ctx.newRemoteAsset(ownerId: userId);
|
||||
final local = await ctx.newLocalAsset(checksum: 'edit-v2', syncedChecksum: 'edit-v1', priorRemoteId: prior.id);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
|
||||
final result = await sut.getCandidates(userId);
|
||||
expect(result.length, 1);
|
||||
expect(result.first.id, local.id);
|
||||
});
|
||||
|
||||
test('excludes reconciled asset with live prior remote', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final prior = await ctx.newRemoteAsset(ownerId: userId);
|
||||
final local = await ctx.newLocalAsset(
|
||||
checksum: 'reverted-2',
|
||||
syncedChecksum: 'reverted-2',
|
||||
priorRemoteId: prior.id,
|
||||
);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
|
||||
final result = await sut.getCandidates(userId);
|
||||
expect(result, isEmpty);
|
||||
});
|
||||
|
||||
test('excludes reconciled asset whose prior remote was trashed', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final prior = await ctx.newRemoteAsset(ownerId: userId, deletedAt: DateTime(2025, 6));
|
||||
final local = await ctx.newLocalAsset(
|
||||
checksum: 'reverted-3',
|
||||
syncedChecksum: 'reverted-3',
|
||||
priorRemoteId: prior.id,
|
||||
);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
|
||||
final result = await sut.getCandidates(userId);
|
||||
expect(result, isEmpty);
|
||||
});
|
||||
|
||||
test('includes reconciled asset whose prior remote was hard-deleted', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final local = await ctx.newLocalAsset(checksum: 'edit-v3', syncedChecksum: 'edit-v3', priorRemoteId: 'gone');
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
|
||||
final result = await sut.getCandidates(userId);
|
||||
expect(result.length, 1);
|
||||
expect(result.first.id, local.id);
|
||||
});
|
||||
|
||||
test('includes asset with null checksum and synced checksum set when onlyHashed is false', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final asset = await ctx.newLocalAsset(checksumOption: const Option.none(), syncedChecksum: 'old');
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
|
||||
|
||||
final result = await sut.getCandidates(userId, onlyHashed: false);
|
||||
expect(result.length, 1);
|
||||
expect(result.first.id, asset.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
|
||||
import '../repository_context.dart';
|
||||
|
||||
void main() {
|
||||
late MediumRepositoryContext ctx;
|
||||
late DriftLocalAlbumRepository sut;
|
||||
|
||||
setUp(() {
|
||||
ctx = MediumRepositoryContext();
|
||||
sut = DriftLocalAlbumRepository(ctx.db);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await ctx.dispose();
|
||||
});
|
||||
|
||||
group('getAssetsToHash', () {
|
||||
test('returns unhashed assets in the album', () async {
|
||||
final album = await ctx.newLocalAlbum();
|
||||
final a = await ctx.newLocalAsset(checksumOption: const Option.none());
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: a.id);
|
||||
|
||||
final result = await sut.getAssetsToHash(album.id);
|
||||
expect(result.map((e) => e.id), [a.id]);
|
||||
});
|
||||
|
||||
test('skips assets that already have a checksum', () async {
|
||||
final album = await ctx.newLocalAlbum();
|
||||
final hashed = await ctx.newLocalAsset(checksum: 'abc');
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: hashed.id);
|
||||
|
||||
expect(await sut.getAssetsToHash(album.id), isEmpty);
|
||||
});
|
||||
|
||||
test('hashes burst members whose representative is in the album (members not album-tied)', () async {
|
||||
// iOS only puts the burst cover in a user album; the hidden members must
|
||||
// still be hashed so they can become backup candidates.
|
||||
final album = await ctx.newLocalAlbum();
|
||||
final rep = await ctx.newLocalAsset(
|
||||
checksumOption: const Option.none(),
|
||||
burstId: 'b1',
|
||||
isBurstRepresentative: true,
|
||||
);
|
||||
final member = await ctx.newLocalAsset(
|
||||
checksumOption: const Option.none(),
|
||||
burstId: 'b1',
|
||||
isBurstRepresentative: false,
|
||||
);
|
||||
// only the representative is an album member
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: rep.id);
|
||||
|
||||
final result = await sut.getAssetsToHash(album.id);
|
||||
expect(result.map((e) => e.id).toSet(), {rep.id, member.id});
|
||||
});
|
||||
|
||||
test('does not hash burst members of a representative in a different album', () async {
|
||||
final album = await ctx.newLocalAlbum();
|
||||
final otherAlbum = await ctx.newLocalAlbum();
|
||||
final rep = await ctx.newLocalAsset(
|
||||
checksumOption: const Option.none(),
|
||||
burstId: 'b1',
|
||||
isBurstRepresentative: true,
|
||||
);
|
||||
await ctx.newLocalAsset(checksumOption: const Option.none(), burstId: 'b1', isBurstRepresentative: false);
|
||||
// rep is in otherAlbum, not the album we query
|
||||
await ctx.newLocalAlbumAsset(albumId: otherAlbum.id, assetId: rep.id);
|
||||
|
||||
expect(await sut.getAssetsToHash(album.id), isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -19,6 +19,105 @@ void main() {
|
||||
await ctx.dispose();
|
||||
});
|
||||
|
||||
group('getBurstParentRemoteId', () {
|
||||
test('returns null until a member of the burst has uploaded', () async {
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true);
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
|
||||
expect(await sut.getBurstParentRemoteId('b1'), isNull);
|
||||
});
|
||||
|
||||
test('returns the representative prior once it has uploaded', () async {
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true, priorRemoteId: 'rep-remote');
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
|
||||
expect(await sut.getBurstParentRemoteId('b1'), 'rep-remote');
|
||||
});
|
||||
|
||||
test('returns an uploaded member prior even when the representative flag has moved away from it', () async {
|
||||
// Moving-cover invariant: the anchor is whichever frame uploaded first, NOT
|
||||
// the current representative. Here the only uploaded frame is a non-rep
|
||||
// (the cover moved to a not-yet-uploaded frame) — it must still anchor the
|
||||
// stack so later frames don't spawn a second one.
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true);
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false, priorRemoteId: 'member-remote');
|
||||
|
||||
expect(await sut.getBurstParentRemoteId('b1'), 'member-remote');
|
||||
});
|
||||
|
||||
test('ignores priors from other bursts', () async {
|
||||
await ctx.newLocalAsset(burstId: 'other', isBurstRepresentative: true, priorRemoteId: 'other-remote');
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true);
|
||||
|
||||
expect(await sut.getBurstParentRemoteId('b1'), isNull);
|
||||
});
|
||||
|
||||
test('falls back to the rep already-synced remote (matched by checksum) for a pre-existing burst', () async {
|
||||
// The whole burst was backed up before this feature, so no local frame ever
|
||||
// stamped a prior. The rep is on the server already (checksum match) — its
|
||||
// remote id must anchor the hidden members so they can still stack.
|
||||
final user = await ctx.newUser();
|
||||
final repRemote = await ctx.newRemoteAsset(ownerId: user.id, checksum: 'rep-sum');
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true, checksum: 'rep-sum');
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false, checksum: 'mem-sum');
|
||||
|
||||
expect(await sut.getBurstParentRemoteId('b1', ownerId: user.id), repRemote.id);
|
||||
});
|
||||
|
||||
test('checksum fallback only matches the rep owned by the given user', () async {
|
||||
final me = await ctx.newUser();
|
||||
final other = await ctx.newUser();
|
||||
await ctx.newRemoteAsset(ownerId: other.id, checksum: 'rep-sum');
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true, checksum: 'rep-sum');
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false, checksum: 'mem-sum');
|
||||
|
||||
// rep is only on another user's server account → no anchor for me.
|
||||
expect(await sut.getBurstParentRemoteId('b1', ownerId: me.id), isNull);
|
||||
});
|
||||
|
||||
test('a stamped local prior wins over the checksum fallback', () async {
|
||||
final user = await ctx.newUser();
|
||||
await ctx.newRemoteAsset(ownerId: user.id, checksum: 'rep-sum');
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true, checksum: 'rep-sum');
|
||||
await ctx.newLocalAsset(
|
||||
burstId: 'b1',
|
||||
isBurstRepresentative: false,
|
||||
checksum: 'mem-sum',
|
||||
priorRemoteId: 'member-remote',
|
||||
);
|
||||
|
||||
expect(await sut.getBurstParentRemoteId('b1', ownerId: user.id), 'member-remote');
|
||||
});
|
||||
|
||||
test('without ownerId the checksum fallback is skipped', () async {
|
||||
final user = await ctx.newUser();
|
||||
await ctx.newRemoteAsset(ownerId: user.id, checksum: 'rep-sum');
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true, checksum: 'rep-sum');
|
||||
|
||||
expect(await sut.getBurstParentRemoteId('b1'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('burstHasRepresentative', () {
|
||||
test('true when the group has a representative', () async {
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true);
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
|
||||
expect(await sut.burstHasRepresentative('b1'), isTrue);
|
||||
});
|
||||
|
||||
test('false for a rep-less group (Keep Everything / re-pick)', () async {
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
|
||||
expect(await sut.burstHasRepresentative('b1'), isFalse);
|
||||
});
|
||||
|
||||
test('false for an unknown burst', () async {
|
||||
expect(await sut.burstHasRepresentative('nope'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('getRemovalCandidates', () {
|
||||
final cutoffDate = DateTime(2024, 1, 1);
|
||||
final beforeCutoff = DateTime(2023, 12, 31);
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
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 '../repository_context.dart';
|
||||
|
||||
void main() {
|
||||
late MediumRepositoryContext ctx;
|
||||
late DriftStackRepository sut;
|
||||
late String userId;
|
||||
|
||||
setUp(() async {
|
||||
ctx = MediumRepositoryContext();
|
||||
sut = DriftStackRepository(ctx.db);
|
||||
final user = await ctx.newUser();
|
||||
userId = user.id;
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await ctx.dispose();
|
||||
});
|
||||
|
||||
group('priorState', () {
|
||||
test('live for a live remote', () async {
|
||||
await ctx.newRemoteAsset(id: 'live', ownerId: userId);
|
||||
expect(await sut.priorState('live'), PriorState.live);
|
||||
});
|
||||
|
||||
test('trashed for a trashed remote', () async {
|
||||
await ctx.newRemoteAsset(id: 'trashed', ownerId: userId, deletedAt: DateTime(2025, 6));
|
||||
expect(await sut.priorState('trashed'), PriorState.trashed);
|
||||
});
|
||||
|
||||
test('trashed for a locked remote (server refuses to stack onto it)', () async {
|
||||
await ctx.newRemoteAsset(id: 'locked', ownerId: userId, visibility: AssetVisibility.locked);
|
||||
expect(await sut.priorState('locked'), PriorState.trashed);
|
||||
});
|
||||
|
||||
test('missing for a remote that was never synced', () async {
|
||||
expect(await sut.priorState('missing'), PriorState.missing);
|
||||
});
|
||||
});
|
||||
|
||||
group('remoteByChecksum', () {
|
||||
test('returns live with the id for a live owned remote', () async {
|
||||
await ctx.newRemoteAsset(id: 'remote-1', ownerId: userId, checksum: 'base-sum');
|
||||
|
||||
final dup = await sut.remoteByChecksum('base-sum', userId);
|
||||
|
||||
expect(dup.state, PriorState.live);
|
||||
expect(dup.remoteId, 'remote-1');
|
||||
});
|
||||
|
||||
test('returns trashed with the id for a trashed owned remote', () async {
|
||||
await ctx.newRemoteAsset(id: 'remote-1', ownerId: userId, checksum: 'base-sum', deletedAt: DateTime(2025, 6));
|
||||
|
||||
final dup = await sut.remoteByChecksum('base-sum', userId);
|
||||
|
||||
expect(dup.state, PriorState.trashed);
|
||||
expect(dup.remoteId, 'remote-1');
|
||||
});
|
||||
|
||||
test('returns trashed with the id for a locked owned remote', () async {
|
||||
await ctx.newRemoteAsset(
|
||||
id: 'remote-1',
|
||||
ownerId: userId,
|
||||
checksum: 'base-sum',
|
||||
visibility: AssetVisibility.locked,
|
||||
);
|
||||
|
||||
final dup = await sut.remoteByChecksum('base-sum', userId);
|
||||
|
||||
expect(dup.state, PriorState.trashed);
|
||||
expect(dup.remoteId, 'remote-1');
|
||||
});
|
||||
|
||||
test('returns missing when no remote has the bytes', () async {
|
||||
await ctx.newRemoteAsset(id: 'remote-1', ownerId: userId, checksum: 'other-sum');
|
||||
|
||||
final dup = await sut.remoteByChecksum('base-sum', userId);
|
||||
|
||||
expect(dup.state, PriorState.missing);
|
||||
expect(dup.remoteId, isNull);
|
||||
});
|
||||
|
||||
test("ignores another user's remote with the same bytes (owner-scoped)", () async {
|
||||
final other = await ctx.newUser();
|
||||
await ctx.newRemoteAsset(id: 'theirs', ownerId: other.id, checksum: 'base-sum');
|
||||
|
||||
final dup = await sut.remoteByChecksum('base-sum', userId);
|
||||
|
||||
expect(dup.state, PriorState.missing);
|
||||
expect(dup.remoteId, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('findStackIdByRemoteId', () {
|
||||
test('returns the stack id for a stacked remote', () async {
|
||||
final base = await ctx.newRemoteAsset(id: 'base', ownerId: userId);
|
||||
final stack = await ctx.newStack(ownerId: userId, primaryAssetId: base.id);
|
||||
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: stack.id);
|
||||
expect(await sut.findStackIdByRemoteId('edit'), stack.id);
|
||||
});
|
||||
|
||||
test('returns null for an unstacked remote', () async {
|
||||
await ctx.newRemoteAsset(id: 'lonely', ownerId: userId);
|
||||
expect(await sut.findStackIdByRemoteId('lonely'), isNull);
|
||||
});
|
||||
|
||||
test('returns null for a trashed remote', () async {
|
||||
final base = await ctx.newRemoteAsset(id: 'base', ownerId: userId);
|
||||
final stack = await ctx.newStack(ownerId: userId, primaryAssetId: base.id);
|
||||
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: stack.id, deletedAt: DateTime(2025, 6));
|
||||
expect(await sut.findStackIdByRemoteId('edit'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('findStackBaseId', () {
|
||||
test('returns the earliest-uploaded member that is not the excluded one', () async {
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
|
||||
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025));
|
||||
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025, 2));
|
||||
|
||||
// base uploaded before the edit → it's the flip target.
|
||||
expect(await sut.findStackBaseId('stack-1', excludeId: 'edit'), 'base');
|
||||
});
|
||||
|
||||
test('returns null when the only member is excluded', () async {
|
||||
final base = await ctx.newRemoteAsset(id: 'solo', ownerId: userId, stackId: 'stack-1');
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: base.id);
|
||||
expect(await sut.findStackBaseId('stack-1', excludeId: 'solo'), isNull);
|
||||
});
|
||||
|
||||
test('skips trashed members', () async {
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
|
||||
await ctx.newRemoteAsset(
|
||||
id: 'base',
|
||||
ownerId: userId,
|
||||
stackId: 'stack-1',
|
||||
uploadedAt: DateTime(2025),
|
||||
deletedAt: DateTime(2025, 6),
|
||||
);
|
||||
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025, 2));
|
||||
expect(await sut.findStackBaseId('stack-1', excludeId: 'edit'), isNull);
|
||||
});
|
||||
|
||||
test('live shape: unstacked hidden motion videos can never win', () async {
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit-still');
|
||||
await ctx.newRemoteAsset(id: 'base-still', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025));
|
||||
await ctx.newRemoteAsset(id: 'edit-still', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025, 2));
|
||||
// The live pair's motion videos: hidden, no stack, uploaded before everything.
|
||||
await ctx.newRemoteAsset(
|
||||
id: 'base-video',
|
||||
ownerId: userId,
|
||||
type: AssetType.video,
|
||||
visibility: AssetVisibility.hidden,
|
||||
uploadedAt: DateTime(2024),
|
||||
);
|
||||
await ctx.newRemoteAsset(
|
||||
id: 'edit-video',
|
||||
ownerId: userId,
|
||||
type: AssetType.video,
|
||||
visibility: AssetVisibility.hidden,
|
||||
uploadedAt: DateTime(2024, 2),
|
||||
);
|
||||
|
||||
expect(await sut.findStackBaseId('stack-1', excludeId: 'edit-still'), 'base-still');
|
||||
});
|
||||
|
||||
test('oldest member wins even when it is a dedup-reused edit (known limit)', () async {
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'prior-edit');
|
||||
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025, 2));
|
||||
// An edit re-used by server dedup, uploaded before the base ever was.
|
||||
await ctx.newRemoteAsset(id: 'dedup-edit', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025));
|
||||
await ctx.newRemoteAsset(id: 'prior-edit', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025, 3));
|
||||
|
||||
// Heuristic is oldest uploaded_at, so the reused edit beats the real base.
|
||||
expect(await sut.findStackBaseId('stack-1', excludeId: 'prior-edit'), 'dedup-edit');
|
||||
});
|
||||
|
||||
test('a member with NULL uploaded_at sorts last', () async {
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
|
||||
await ctx.newRemoteAsset(id: 'unsynced', ownerId: userId, stackId: 'stack-1');
|
||||
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025, 2));
|
||||
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025, 3));
|
||||
|
||||
expect(await sut.findStackBaseId('stack-1', excludeId: 'edit'), 'base');
|
||||
});
|
||||
});
|
||||
|
||||
group('findRevertReconcileTargets', () {
|
||||
test('finds a local that hashed back to a non-primary stack member', () async {
|
||||
// Stack: primary = edit, also holds base. The local's checksum matches base.
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
|
||||
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', checksum: 'base-sum');
|
||||
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', checksum: 'edit-sum');
|
||||
await ctx.newLocalAsset(id: 'local-1', checksum: 'base-sum', priorRemoteId: 'edit');
|
||||
|
||||
final targets = await sut.findRevertReconcileTargets();
|
||||
|
||||
expect(targets, hasLength(1));
|
||||
expect(targets.first.stackId, 'stack-1');
|
||||
expect(targets.first.newPrimaryId, 'base');
|
||||
expect(targets.first.localAssetId, 'local-1');
|
||||
expect(targets.first.localAssetChecksum, 'base-sum');
|
||||
});
|
||||
|
||||
test('stops matching once the primary flips to the matched member', () async {
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
|
||||
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', checksum: 'base-sum');
|
||||
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', checksum: 'edit-sum');
|
||||
await ctx.newLocalAsset(id: 'local-1', checksum: 'base-sum', priorRemoteId: 'edit');
|
||||
|
||||
expect(await sut.findRevertReconcileTargets(), hasLength(1));
|
||||
|
||||
await sut.setPrimary('stack-1', 'base');
|
||||
expect(await sut.findRevertReconcileTargets(), isEmpty);
|
||||
});
|
||||
|
||||
test('returns nothing when the local already matches the primary', () async {
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
|
||||
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', checksum: 'edit-sum');
|
||||
await ctx.newLocalAsset(id: 'local-1', checksum: 'edit-sum', priorRemoteId: 'edit');
|
||||
|
||||
expect(await sut.findRevertReconcileTargets(), isEmpty);
|
||||
});
|
||||
|
||||
test('ignores a local whose prior remote was trashed', () async {
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
|
||||
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', checksum: 'base-sum');
|
||||
await ctx.newRemoteAsset(
|
||||
id: 'edit',
|
||||
ownerId: userId,
|
||||
stackId: 'stack-1',
|
||||
checksum: 'edit-sum',
|
||||
deletedAt: DateTime(2025, 6),
|
||||
);
|
||||
await ctx.newLocalAsset(id: 'local-1', checksum: 'base-sum', priorRemoteId: 'edit');
|
||||
|
||||
expect(await sut.findRevertReconcileTargets(), isEmpty);
|
||||
});
|
||||
|
||||
test('ignores a local whose prior is not in any stack', () async {
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
|
||||
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', checksum: 'base-sum');
|
||||
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', checksum: 'edit-sum');
|
||||
await ctx.newRemoteAsset(id: 'unstacked', ownerId: userId, checksum: 'other-sum');
|
||||
await ctx.newLocalAsset(id: 'local-1', checksum: 'base-sum', priorRemoteId: 'unstacked');
|
||||
|
||||
expect(await sut.findRevertReconcileTargets(), isEmpty);
|
||||
});
|
||||
|
||||
test('leaves a manual stack of two backed-up locals alone (no ping-pong)', () async {
|
||||
// The user stacked two ordinary photos by hand. Each local is steady-state:
|
||||
// synced == checksum and prior = its own member, so neither side may ever
|
||||
// flip the primary back and forth.
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'remote-a');
|
||||
await ctx.newRemoteAsset(id: 'remote-a', ownerId: userId, stackId: 'stack-1', checksum: 'a-sum');
|
||||
await ctx.newRemoteAsset(id: 'remote-b', ownerId: userId, stackId: 'stack-1', checksum: 'b-sum');
|
||||
await ctx.newLocalAsset(id: 'local-a', checksum: 'a-sum', syncedChecksum: 'a-sum', priorRemoteId: 'remote-a');
|
||||
await ctx.newLocalAsset(id: 'local-b', checksum: 'b-sum', syncedChecksum: 'b-sum', priorRemoteId: 'remote-b');
|
||||
|
||||
expect(await sut.findRevertReconcileTargets(), isEmpty);
|
||||
});
|
||||
|
||||
test('finds a true revert: prior is the edit, checksum hashed back to the base', () async {
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
|
||||
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', checksum: 'base-sum');
|
||||
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', checksum: 'edit-sum');
|
||||
// Unreconciled: the chain last synced the edit bytes, the local now holds the base bytes.
|
||||
await ctx.newLocalAsset(id: 'local-1', checksum: 'base-sum', syncedChecksum: 'edit-sum', priorRemoteId: 'edit');
|
||||
|
||||
final targets = await sut.findRevertReconcileTargets();
|
||||
|
||||
expect(targets, hasLength(1));
|
||||
expect(targets.first.stackId, 'stack-1');
|
||||
expect(targets.first.newPrimaryId, 'base');
|
||||
expect(targets.first.localAssetId, 'local-1');
|
||||
expect(targets.first.localAssetChecksum, 'base-sum');
|
||||
});
|
||||
|
||||
test('stops matching once the flip writes synced = checksum', () async {
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
|
||||
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', checksum: 'base-sum');
|
||||
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', checksum: 'edit-sum');
|
||||
await ctx.newLocalAsset(id: 'local-1', checksum: 'base-sum', syncedChecksum: 'edit-sum', priorRemoteId: 'edit');
|
||||
|
||||
final targets = await sut.findRevertReconcileTargets();
|
||||
expect(targets, hasLength(1));
|
||||
|
||||
// The reconcile flip rolls the stamps forward; that's what makes it self-limiting.
|
||||
final target = targets.first;
|
||||
await DriftLocalAssetRepository(
|
||||
ctx.db,
|
||||
).markSynced(target.localAssetId, priorRemoteId: target.newPrimaryId, syncedChecksum: target.localAssetChecksum);
|
||||
|
||||
expect(await sut.findRevertReconcileTargets(), isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||
|
||||
import '../repository_context.dart';
|
||||
|
||||
void main() {
|
||||
late MediumRepositoryContext ctx;
|
||||
late SyncStreamRepository sut;
|
||||
|
||||
setUp(() {
|
||||
ctx = MediumRepositoryContext();
|
||||
sut = SyncStreamRepository(ctx.db);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await ctx.dispose();
|
||||
});
|
||||
|
||||
group('pruneAssets', () {
|
||||
test('deletes foreign orphans and keeps owned, partner, and in-album assets', () async {
|
||||
final me = await ctx.newUser();
|
||||
final partner = await ctx.newUser();
|
||||
final stranger = await ctx.newUser();
|
||||
await ctx.newAuthUser(id: me.id);
|
||||
await ctx.newPartner(sharedById: partner.id, sharedWithId: me.id);
|
||||
|
||||
final own = await ctx.newRemoteAsset(ownerId: me.id);
|
||||
final fromPartner = await ctx.newRemoteAsset(ownerId: partner.id);
|
||||
final shared = await ctx.newRemoteAsset(ownerId: stranger.id);
|
||||
await ctx.newRemoteAsset(ownerId: stranger.id);
|
||||
|
||||
final album = await ctx.newRemoteAlbum(ownerId: me.id);
|
||||
await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: shared.id);
|
||||
|
||||
await sut.pruneAssets();
|
||||
|
||||
final remaining = await ctx.db.select(ctx.db.remoteAssetEntity).get();
|
||||
expect(remaining.map((a) => a.id), unorderedEquals([own.id, fromPartner.id, shared.id]));
|
||||
});
|
||||
|
||||
test('does nothing when there is no authenticated user', () async {
|
||||
final stranger = await ctx.newUser();
|
||||
final orphan = await ctx.newRemoteAsset(ownerId: stranger.id);
|
||||
|
||||
await sut.pruneAssets();
|
||||
|
||||
final remaining = await ctx.db.select(ctx.db.remoteAssetEntity).get();
|
||||
expect(remaining.map((a) => a.id), [orphan.id]);
|
||||
});
|
||||
|
||||
test('prunes every stale foreign asset in a large data set', () async {
|
||||
final stranger = await ctx.newUser();
|
||||
await ctx.newAuthUser();
|
||||
for (var i = 0; i < 600; i++) {
|
||||
await ctx.newRemoteAsset(ownerId: stranger.id);
|
||||
}
|
||||
|
||||
await sut.pruneAssets();
|
||||
|
||||
final remaining = await ctx.db.select(ctx.db.remoteAssetEntity).get();
|
||||
expect(remaining, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
|
||||
@@ -48,6 +50,126 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('burst display', () {
|
||||
test('local-only burst shows only the representative tile', () async {
|
||||
final user = await ctx.newUser();
|
||||
final album = await ctx.newLocalAlbum(backupSelection: .selected);
|
||||
final rep = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: true);
|
||||
final member1 = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
final member2 = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
for (final a in [rep, member1, member2]) {
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: a.id);
|
||||
}
|
||||
|
||||
final query = sut.main([user.id], .day);
|
||||
|
||||
final buckets = await query.bucketSource().first;
|
||||
expect(buckets.fold<int>(0, (sum, b) => sum + b.assetCount), 1);
|
||||
|
||||
final assets = await query.assetSource(0, 10);
|
||||
expect(assets, hasLength(1));
|
||||
expect(assets.single.localId, rep.id);
|
||||
});
|
||||
|
||||
test('remote burst members are hidden by the local flag even before the stack syncs', () async {
|
||||
// Both remotes have stack_id NULL (the transient window before the server's
|
||||
// StackUpdate reaches the client). The normal stack rule would show both;
|
||||
// clause B hides the non-rep by its local flag, while stack_id IS NULL keeps the rep.
|
||||
final user = await ctx.newUser();
|
||||
final repRemote = await ctx.newRemoteAsset(ownerId: user.id, checksum: 'c-rep');
|
||||
await ctx.newRemoteAsset(ownerId: user.id, checksum: 'c-member');
|
||||
await ctx.newLocalAsset(checksum: 'c-rep', burstId: 'b1', isBurstRepresentative: true);
|
||||
await ctx.newLocalAsset(checksum: 'c-member', burstId: 'b1', isBurstRepresentative: false);
|
||||
|
||||
final query = sut.main([user.id], .day);
|
||||
|
||||
final buckets = await query.bucketSource().first;
|
||||
expect(buckets.fold<int>(0, (sum, b) => sum + b.assetCount), 1);
|
||||
|
||||
final assets = await query.assetSource(0, 10);
|
||||
expect(assets, hasLength(1));
|
||||
expect((assets.single as RemoteAsset).id, repRemote.id);
|
||||
});
|
||||
|
||||
test('a synced burst stack still shows its primary after a Photos re-pick moves the local rep flag', () async {
|
||||
// Regression: once the stack synced (stack_id set, primary = old rep) the
|
||||
// user re-picks the cover in Photos, so the old rep's local row flips to
|
||||
// is_burst_representative = 0. Clause B must NOT hide the synced primary —
|
||||
// the stack would vanish from the grid. Scoping clause B to stack_id IS NULL
|
||||
// hands the synced case back to the primary rule.
|
||||
final user = await ctx.newUser();
|
||||
final primary = await ctx.newRemoteAsset(ownerId: user.id, checksum: 'c-rep');
|
||||
final member = await ctx.newRemoteAsset(ownerId: user.id, checksum: 'c-member');
|
||||
final stack = await ctx.newStack(ownerId: user.id, primaryAssetId: primary.id);
|
||||
await (ctx.db.update(
|
||||
ctx.db.remoteAssetEntity,
|
||||
)..where((t) => t.id.isIn([primary.id, member.id]))).write(RemoteAssetEntityCompanion(stackId: Value(stack.id)));
|
||||
// local rep flag has moved off the old rep (now a non-rep frame locally)
|
||||
await ctx.newLocalAsset(checksum: 'c-rep', burstId: 'b1', isBurstRepresentative: false);
|
||||
await ctx.newLocalAsset(checksum: 'c-member', burstId: 'b1', isBurstRepresentative: false);
|
||||
|
||||
final query = sut.main([user.id], .day);
|
||||
|
||||
final buckets = await query.bucketSource().first;
|
||||
expect(buckets.fold<int>(0, (sum, b) => sum + b.assetCount), 1);
|
||||
|
||||
final assets = await query.assetSource(0, 10);
|
||||
expect(assets, hasLength(1));
|
||||
expect((assets.single as RemoteAsset).id, primary.id);
|
||||
});
|
||||
|
||||
test('a rep-less local burst group shows every frame as an individual (no vanish)', () async {
|
||||
// After "Keep Everything"/re-pick a burst group can end up with zero
|
||||
// is_burst_representative=1 frames. Those frames must NOT all hide — show
|
||||
// each as its own tile so nothing vanishes from the grid.
|
||||
final user = await ctx.newUser();
|
||||
final album = await ctx.newLocalAlbum(backupSelection: .selected);
|
||||
final f1 = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
final f2 = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
final f3 = await ctx.newLocalAsset(burstId: 'b1', isBurstRepresentative: false);
|
||||
for (final a in [f1, f2, f3]) {
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: a.id);
|
||||
}
|
||||
|
||||
final query = sut.main([user.id], .day);
|
||||
|
||||
final buckets = await query.bucketSource().first;
|
||||
expect(buckets.fold<int>(0, (sum, b) => sum + b.assetCount), 3);
|
||||
|
||||
final assets = await query.assetSource(0, 10);
|
||||
expect(assets.map((a) => a.localId).toSet(), {f1.id, f2.id, f3.id});
|
||||
});
|
||||
|
||||
test(
|
||||
'a synced stack does not duplicate when the rep flag moves onto a non-primary member (Keep Everything)',
|
||||
() async {
|
||||
// After "Keep Everything", iOS moves representsBurst onto a different frame.
|
||||
// If that frame is a non-primary member of an already-synced stack, the grid
|
||||
// must still show only the stack primary — not also surface the moved-rep
|
||||
// member as a second tile (the clause-A duplicate this removed).
|
||||
final user = await ctx.newUser();
|
||||
final primary = await ctx.newRemoteAsset(ownerId: user.id, checksum: 'c-primary');
|
||||
final member = await ctx.newRemoteAsset(ownerId: user.id, checksum: 'c-member');
|
||||
final stack = await ctx.newStack(ownerId: user.id, primaryAssetId: primary.id);
|
||||
await (ctx.db.update(ctx.db.remoteAssetEntity)..where((t) => t.id.isIn([primary.id, member.id]))).write(
|
||||
RemoteAssetEntityCompanion(stackId: Value(stack.id)),
|
||||
);
|
||||
// the local rep flag now sits on the member frame (matches the non-primary remote)
|
||||
await ctx.newLocalAsset(checksum: 'c-primary', burstId: 'b1', isBurstRepresentative: false);
|
||||
await ctx.newLocalAsset(checksum: 'c-member', burstId: 'b1', isBurstRepresentative: true);
|
||||
|
||||
final query = sut.main([user.id], .day);
|
||||
|
||||
final buckets = await query.bucketSource().first;
|
||||
expect(buckets.fold<int>(0, (sum, b) => sum + b.assetCount), 1);
|
||||
|
||||
final assets = await query.assetSource(0, 10);
|
||||
expect(assets, hasLength(1));
|
||||
expect((assets.single as RemoteAsset).id, primary.id);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('person assets', () {
|
||||
test('does not duplicate an asset that has multiple face records for the same person', () async {
|
||||
// Regression check for #26723: an INNER JOIN between remote_asset_entity and asset_face_entity
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/memory.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||
@@ -19,6 +18,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
@@ -73,20 +73,6 @@ class MediumRepositoryContext {
|
||||
);
|
||||
}
|
||||
|
||||
Future<AuthUserEntityData> newAuthUser({String? id, String? email, AvatarColor? avatarColor}) async {
|
||||
id ??= TestUtils.uuid();
|
||||
return await db
|
||||
.into(db.authUserEntity)
|
||||
.insertReturning(
|
||||
AuthUserEntityCompanion(
|
||||
id: .new(id),
|
||||
email: .new(email ?? '$id@test.com'),
|
||||
name: .new('user_$id'),
|
||||
avatarColor: .new(avatarColor ?? TestUtils.randElement(AvatarColor.values)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> newPartner({required String sharedById, required String sharedWithId, bool? inTimeline}) {
|
||||
return db
|
||||
.into(db.partnerEntity)
|
||||
@@ -117,6 +103,7 @@ class MediumRepositoryContext {
|
||||
String? stackId,
|
||||
String? thumbHash,
|
||||
String? libraryId,
|
||||
DateTime? uploadedAt,
|
||||
}) async {
|
||||
id ??= TestUtils.uuid();
|
||||
createdAt ??= TestUtils.date();
|
||||
@@ -143,6 +130,19 @@ class MediumRepositoryContext {
|
||||
localDateTime: .new(createdAt.toLocal()),
|
||||
thumbHash: .new(TestUtils.uuid(thumbHash)),
|
||||
libraryId: .new(TestUtils.uuid(libraryId)),
|
||||
uploadedAt: .new(uploadedAt),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<StackEntityData> newStack({String? id, String? ownerId, required String primaryAssetId}) {
|
||||
return db
|
||||
.into(db.stackEntity)
|
||||
.insertReturning(
|
||||
StackEntityCompanion(
|
||||
id: .new(TestUtils.uuid(id)),
|
||||
ownerId: .new(TestUtils.uuid(ownerId)),
|
||||
primaryAssetId: .new(primaryAssetId),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -276,6 +276,10 @@ class MediumRepositoryContext {
|
||||
int? durationMs,
|
||||
int? orientation,
|
||||
DateTime? updatedAt,
|
||||
String? priorRemoteId,
|
||||
String? syncedChecksum,
|
||||
String? burstId,
|
||||
bool? isBurstRepresentative,
|
||||
}) async {
|
||||
id ??= TestUtils.uuid();
|
||||
return db
|
||||
@@ -297,6 +301,10 @@ class MediumRepositoryContext {
|
||||
adjustmentTime: _resolveUndefined(adjustmentTime, adjustmentTimeOption, DateTime.now()),
|
||||
latitude: .new(latitude ?? TestUtils.randDouble(-90, 90)),
|
||||
longitude: .new(longitude ?? TestUtils.randDouble(-180, 180)),
|
||||
priorRemoteId: .new(priorRemoteId),
|
||||
syncedChecksum: .new(syncedChecksum),
|
||||
burstId: .new(burstId),
|
||||
isBurstRepresentative: .new(isBurstRepresentative ?? false),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,29 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:immich_mobile/utils/version_compatibility.dart';
|
||||
|
||||
void main() {
|
||||
group('app major version behind server', () {
|
||||
const message =
|
||||
'Your mobile app version is not compatible with the server! Please update your mobile app to the latest version.';
|
||||
test('getVersionCompatibilityMessage', () {
|
||||
String? result;
|
||||
|
||||
test('returns message when app major is behind server major', () {
|
||||
final result = getVersionCompatibilityMessage(
|
||||
const SemVer(major: 2, minor: 0, patch: 0),
|
||||
const SemVer(major: 1, minor: 200, patch: 0),
|
||||
);
|
||||
expect(result, message);
|
||||
});
|
||||
result = getVersionCompatibilityMessage(1, 106, 1, 105);
|
||||
expect(
|
||||
result,
|
||||
'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login',
|
||||
);
|
||||
|
||||
test('returns null when app major matches server major', () {
|
||||
final result = getVersionCompatibilityMessage(
|
||||
const SemVer(major: 2, minor: 0, patch: 0),
|
||||
const SemVer(major: 2, minor: 0, patch: 0),
|
||||
);
|
||||
expect(result, null);
|
||||
});
|
||||
});
|
||||
result = getVersionCompatibilityMessage(1, 107, 1, 105);
|
||||
expect(
|
||||
result,
|
||||
'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login',
|
||||
);
|
||||
|
||||
group('app major version too far ahead of server', () {
|
||||
const message =
|
||||
'Your server version is not compatible with the mobile app! Please update your server to the latest version.';
|
||||
result = getVersionCompatibilityMessage(1, 106, 1, 106);
|
||||
expect(result, null);
|
||||
|
||||
test('returns message when app major is more than one ahead of server', () {
|
||||
final result = getVersionCompatibilityMessage(
|
||||
const SemVer(major: 1, minor: 200, patch: 0),
|
||||
const SemVer(major: 3, minor: 0, patch: 0),
|
||||
);
|
||||
expect(result, message);
|
||||
});
|
||||
result = getVersionCompatibilityMessage(1, 107, 1, 106);
|
||||
expect(result, null);
|
||||
|
||||
test('returns null when app major is exactly one ahead of server', () {
|
||||
final result = getVersionCompatibilityMessage(
|
||||
const SemVer(major: 1, minor: 200, patch: 0),
|
||||
const SemVer(major: 2, minor: 0, patch: 0),
|
||||
);
|
||||
expect(result, null);
|
||||
});
|
||||
result = getVersionCompatibilityMessage(1, 107, 1, 108);
|
||||
expect(result, null);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
|
||||
|
||||
import '../../../unit/presentation_context.dart';
|
||||
|
||||
void main() {
|
||||
late Drift db;
|
||||
|
||||
setUpAll(() async {
|
||||
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
|
||||
await SettingsRepository.ensureInitialized(db);
|
||||
});
|
||||
|
||||
tearDownAll(() async {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
LocalAsset makeLocalAsset(String id, {String? checksum}) => LocalAsset(
|
||||
id: id,
|
||||
name: '$id.jpg',
|
||||
checksum: checksum,
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2025),
|
||||
updatedAt: DateTime(2025),
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
|
||||
RemoteAsset makeMergedRemoteAsset({String? localChecksum}) => RemoteAsset(
|
||||
id: 'R',
|
||||
localId: 'X',
|
||||
name: 'x.jpg',
|
||||
ownerId: 'owner',
|
||||
checksum: 'server',
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2025),
|
||||
updatedAt: DateTime(2025),
|
||||
isEdited: false,
|
||||
localChecksum: localChecksum,
|
||||
);
|
||||
|
||||
group('local image provider cache keys include checksum', () {
|
||||
test('LocalThumbProvider with a different checksum is a different key', () {
|
||||
final c1 = LocalThumbProvider(id: 'L', assetType: AssetType.image, checksum: 'c1');
|
||||
final c2 = LocalThumbProvider(id: 'L', assetType: AssetType.image, checksum: 'c2');
|
||||
final c1Again = LocalThumbProvider(id: 'L', assetType: AssetType.image, checksum: 'c1');
|
||||
|
||||
// an on-device edit keeps the id but re-hashes to a new checksum → must miss the cache
|
||||
expect(c1 == c2, isFalse);
|
||||
expect(c1.hashCode == c2.hashCode, isFalse);
|
||||
// same id + same checksum → same key (cache hit)
|
||||
expect(c1 == c1Again, isTrue);
|
||||
expect(c1.hashCode, c1Again.hashCode);
|
||||
});
|
||||
|
||||
test('LocalFullImageProvider with a different checksum is a different key', () {
|
||||
final c1 = LocalFullImageProvider(
|
||||
id: 'L',
|
||||
assetType: AssetType.image,
|
||||
size: const Size(100, 100),
|
||||
isAnimated: false,
|
||||
checksum: 'c1',
|
||||
);
|
||||
final c2 = LocalFullImageProvider(
|
||||
id: 'L',
|
||||
assetType: AssetType.image,
|
||||
size: const Size(100, 100),
|
||||
isAnimated: false,
|
||||
checksum: 'c2',
|
||||
);
|
||||
|
||||
expect(c1 == c2, isFalse);
|
||||
expect(c1.hashCode == c2.hashCode, isFalse);
|
||||
});
|
||||
|
||||
test('LocalThumbProvider null vs non-null checksum is a different key', () {
|
||||
final unhashed = LocalThumbProvider(id: 'L', assetType: AssetType.image);
|
||||
final hashed = LocalThumbProvider(id: 'L', assetType: AssetType.image, checksum: 'c1');
|
||||
|
||||
// a rehash takes the checksum null → 'c1'; the stale render must not be reused
|
||||
expect(unhashed == hashed, isFalse);
|
||||
expect(hashed == unhashed, isFalse);
|
||||
});
|
||||
|
||||
test('LocalFullImageProvider null vs non-null checksum is a different key', () {
|
||||
final unhashed = LocalFullImageProvider(
|
||||
id: 'L',
|
||||
assetType: AssetType.image,
|
||||
size: const Size(100, 100),
|
||||
isAnimated: false,
|
||||
);
|
||||
final hashed = LocalFullImageProvider(
|
||||
id: 'L',
|
||||
assetType: AssetType.image,
|
||||
size: const Size(100, 100),
|
||||
isAnimated: false,
|
||||
checksum: 'c1',
|
||||
);
|
||||
|
||||
expect(unhashed == hashed, isFalse);
|
||||
expect(hashed == unhashed, isFalse);
|
||||
});
|
||||
|
||||
test('LocalThumbProvider equality ignores size', () {
|
||||
final small = LocalThumbProvider(id: 'L', assetType: AssetType.image, checksum: 'c1', size: const Size(50, 50));
|
||||
final big = LocalThumbProvider(id: 'L', assetType: AssetType.image, checksum: 'c1', size: const Size(200, 200));
|
||||
|
||||
// viewer fast-path: any cached thumb render is reusable regardless of requested size
|
||||
expect(small == big, isTrue);
|
||||
expect(small.hashCode, big.hashCode);
|
||||
});
|
||||
|
||||
test('LocalFullImageProvider with same id, size, isAnimated and checksum is equal', () {
|
||||
final a = LocalFullImageProvider(
|
||||
id: 'L',
|
||||
assetType: AssetType.image,
|
||||
size: const Size(100, 100),
|
||||
isAnimated: false,
|
||||
checksum: 'c1',
|
||||
);
|
||||
final b = LocalFullImageProvider(
|
||||
id: 'L',
|
||||
assetType: AssetType.image,
|
||||
size: const Size(100, 100),
|
||||
isAnimated: false,
|
||||
checksum: 'c1',
|
||||
);
|
||||
|
||||
expect(a == b, isTrue);
|
||||
expect(a.hashCode, b.hashCode);
|
||||
});
|
||||
});
|
||||
|
||||
group('factory checksum plumbing', () {
|
||||
test('getThumbnailImageProvider carries the local asset checksum', () {
|
||||
final provider = getThumbnailImageProvider(makeLocalAsset('X', checksum: 'c1'));
|
||||
|
||||
expect(provider, isA<LocalThumbProvider>());
|
||||
expect((provider as LocalThumbProvider).checksum, 'c1');
|
||||
});
|
||||
|
||||
test('getFullImageProvider carries the local asset checksum', () {
|
||||
final provider = getFullImageProvider(makeLocalAsset('X', checksum: 'c1'));
|
||||
|
||||
expect(provider, isA<LocalFullImageProvider>());
|
||||
expect((provider as LocalFullImageProvider).checksum, 'c1');
|
||||
});
|
||||
|
||||
test('same id with a different checksum produces unequal providers', () {
|
||||
final thumb1 = getThumbnailImageProvider(makeLocalAsset('X', checksum: 'c1'));
|
||||
final thumb2 = getThumbnailImageProvider(makeLocalAsset('X', checksum: 'c2'));
|
||||
final full1 = getFullImageProvider(makeLocalAsset('X', checksum: 'c1'));
|
||||
final full2 = getFullImageProvider(makeLocalAsset('X', checksum: 'c2'));
|
||||
|
||||
expect(thumb1 == thumb2, isFalse);
|
||||
expect(full1 == full2, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('RemoteAsset localChecksum preference', () {
|
||||
test('merged remote keys local renders by localChecksum when set', () {
|
||||
final asset = makeMergedRemoteAsset(localChecksum: 'device');
|
||||
|
||||
// localId set → hasLocal, so the factory takes the local path
|
||||
expect(asset.hasLocal, isTrue);
|
||||
|
||||
final thumb = getThumbnailImageProvider(asset);
|
||||
expect(thumb, isA<LocalThumbProvider>());
|
||||
expect((thumb as LocalThumbProvider).checksum, 'device');
|
||||
|
||||
final full = getFullImageProvider(asset);
|
||||
expect(full, isA<LocalFullImageProvider>());
|
||||
expect((full as LocalFullImageProvider).checksum, 'device');
|
||||
});
|
||||
|
||||
test('merged remote without a localChecksum renders the remote, not the local bytes', () async {
|
||||
// A prior-linked local that hasn't rehashed yet has no trustworthy cache
|
||||
// key — its bytes may differ from the server checksum.
|
||||
await PresentationContext.create();
|
||||
final asset = makeMergedRemoteAsset();
|
||||
|
||||
final thumb = getThumbnailImageProvider(asset);
|
||||
expect(thumb, isNot(isA<LocalThumbProvider>()));
|
||||
|
||||
final full = getFullImageProvider(asset);
|
||||
expect(full, isNot(isA<LocalFullImageProvider>()));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/services/background_upload.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/utils/upload_speed_calculator.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class _MockForegroundUploadService extends Mock implements ForegroundUploadService {}
|
||||
|
||||
class _MockBackgroundUploadService extends Mock implements BackgroundUploadService {}
|
||||
|
||||
class _MockUploadSpeedManager extends Mock implements UploadSpeedManager {}
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
registerFallbackValue(Completer<void>());
|
||||
registerFallbackValue(const UploadCallbacks());
|
||||
registerFallbackValue(<String>{});
|
||||
});
|
||||
|
||||
late _MockForegroundUploadService fg;
|
||||
late _MockBackgroundUploadService bg;
|
||||
late _MockUploadSpeedManager speed;
|
||||
late DriftBackupNotifier sut;
|
||||
|
||||
setUp(() {
|
||||
fg = _MockForegroundUploadService();
|
||||
bg = _MockBackgroundUploadService();
|
||||
speed = _MockUploadSpeedManager();
|
||||
sut = DriftBackupNotifier(fg, bg, speed);
|
||||
});
|
||||
|
||||
tearDown(() => sut.dispose());
|
||||
|
||||
UploadCallbacks callbacksOf(Invocation inv) => inv.namedArguments[#callbacks] as UploadCallbacks;
|
||||
|
||||
void whenUpload(Future<({int attempted, bool hadBurst})> Function(Invocation) body) {
|
||||
when(
|
||||
() => fg.uploadCandidates(
|
||||
any(),
|
||||
any(),
|
||||
callbacks: any(named: 'callbacks'),
|
||||
skipIds: any(named: 'skipIds'),
|
||||
),
|
||||
).thenAnswer(body);
|
||||
}
|
||||
|
||||
int uploadCallCount() => verify(
|
||||
() => fg.uploadCandidates(
|
||||
any(),
|
||||
any(),
|
||||
callbacks: any(named: 'callbacks'),
|
||||
skipIds: any(named: 'skipIds'),
|
||||
),
|
||||
).callCount;
|
||||
|
||||
group('startForegroundBackup multi-pass loop', () {
|
||||
test('keeps running passes while a pass still uploads burst frames', () async {
|
||||
// The iOS-burst path: the representative uploads on pass 0, its member only
|
||||
// becomes eligible afterwards (pass 1). Each burst pass reports hadBurst so
|
||||
// the loop keeps going; it stops once a pass attempts nothing new.
|
||||
var pass = 0;
|
||||
whenUpload((inv) async {
|
||||
if (pass == 0) {
|
||||
callbacksOf(inv).onSuccess?.call('rep', 'remote-rep');
|
||||
pass++;
|
||||
return (attempted: 1, hadBurst: true);
|
||||
}
|
||||
if (pass == 1) {
|
||||
callbacksOf(inv).onSuccess?.call('member', 'remote-member');
|
||||
pass++;
|
||||
return (attempted: 1, hadBurst: true);
|
||||
}
|
||||
return (attempted: 0, hadBurst: false);
|
||||
});
|
||||
|
||||
await sut.startForegroundBackup('u1');
|
||||
|
||||
// pass0 -> rep, pass1 -> member, pass2 -> nothing left to attempt -> break
|
||||
expect(uploadCallCount(), 3);
|
||||
});
|
||||
|
||||
test('stops after one pass for a non-burst library (no burst frames)', () async {
|
||||
// No burst frames -> hadBurst is false, so nothing can be unblocked by a
|
||||
// second pass and the loop stops immediately. No extra candidate query.
|
||||
whenUpload((inv) async {
|
||||
callbacksOf(inv).onSuccess?.call('a', 'remote-a');
|
||||
return (attempted: 1, hadBurst: false);
|
||||
});
|
||||
|
||||
await sut.startForegroundBackup('u1');
|
||||
|
||||
expect(uploadCallCount(), 1);
|
||||
});
|
||||
|
||||
test('never exceeds the max pass cap even if every pass has burst work', () async {
|
||||
// Every pass attempts something and reports hadBurst, so only the cap can
|
||||
// stop the loop.
|
||||
var n = 0;
|
||||
whenUpload((inv) async {
|
||||
callbacksOf(inv).onSuccess?.call('id-${n++}', 'remote');
|
||||
return (attempted: 1, hadBurst: true);
|
||||
});
|
||||
|
||||
await sut.startForegroundBackup('u1');
|
||||
|
||||
expect(uploadCallCount(), 6);
|
||||
});
|
||||
|
||||
test('carries already-uploaded ids forward as skipIds so a later pass never re-uploads them', () async {
|
||||
final skipSnapshots = <Set<String>>[];
|
||||
var n = 0;
|
||||
whenUpload((inv) async {
|
||||
skipSnapshots.add({...?(inv.namedArguments[#skipIds] as Set<String>?)});
|
||||
if (n < 2) {
|
||||
callbacksOf(inv).onSuccess?.call('id-$n', 'remote');
|
||||
n++;
|
||||
return (attempted: 1, hadBurst: true);
|
||||
}
|
||||
return (attempted: 0, hadBurst: false);
|
||||
});
|
||||
|
||||
await sut.startForegroundBackup('u1');
|
||||
|
||||
expect(skipSnapshots[0], isEmpty);
|
||||
expect(skipSnapshots[1], {'id-0'});
|
||||
expect(skipSnapshots[2], {'id-0', 'id-1'});
|
||||
});
|
||||
|
||||
test('the loop stops when its token is superseded mid-run instead of running to the cap', () async {
|
||||
// Re-entrancy guard: a restart (app resume / page tap) completes and
|
||||
// replaces the loop's token. The captured token then reads as completed, so
|
||||
// the loop must break instead of marching to the cap against shared state.
|
||||
// Every pass reports hadBurst, so only the token guard can stop it.
|
||||
var calls = 0;
|
||||
whenUpload((inv) async {
|
||||
calls++;
|
||||
callbacksOf(inv).onSuccess?.call('id-$calls', 'remote');
|
||||
sut.stopForegroundBackup(); // supersede the in-flight loop's token
|
||||
return (attempted: 1, hadBurst: true);
|
||||
});
|
||||
|
||||
await sut.startForegroundBackup('u1');
|
||||
|
||||
// exactly one pass ran; the superseded token broke the loop next iteration.
|
||||
expect(calls, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,813 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.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/services/store.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../api.mocks.dart';
|
||||
import '../domain/service.mock.dart';
|
||||
import '../infrastructure/repository.mock.dart';
|
||||
import '../mocks/asset_entity.mock.dart';
|
||||
import '../repository.mocks.dart';
|
||||
|
||||
void main() {
|
||||
late ForegroundUploadService sut;
|
||||
late MockUploadRepository mockUpload;
|
||||
late MockStorageRepository mockStorage;
|
||||
late MockDriftBackupRepository mockBackup;
|
||||
late MockConnectivityApi mockConnectivity;
|
||||
late MockAssetMediaRepository mockAssetMedia;
|
||||
late MockNativeSyncApi mockNativeApi;
|
||||
late MockDriftLocalAssetRepository mockLocalAsset;
|
||||
late MockEditRevertService mockEditRevert;
|
||||
late MockDriftStackRepository mockStack;
|
||||
late Drift db;
|
||||
late Directory tmp;
|
||||
|
||||
final edited = LocalAsset(
|
||||
id: 'edited-1',
|
||||
name: 'edited-1.jpg',
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2025, 1, 1, 12),
|
||||
updatedAt: DateTime(2025, 1, 1, 12),
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
checksum: 'edited-sha1',
|
||||
// 30s past createdAt → the edit gate fires.
|
||||
adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30),
|
||||
);
|
||||
|
||||
setUpAll(() async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
registerFallbackValue(edited);
|
||||
registerFallbackValue(File('/tmp/fallback'));
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.flutter.io/path_provider'),
|
||||
(_) async => 'test',
|
||||
);
|
||||
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
|
||||
await StoreService.init(storeRepository: DriftStoreRepository(db));
|
||||
await SettingsRepository.ensureInitialized(db);
|
||||
await Store.put(StoreKey.serverEndpoint, 'http://test-server.com');
|
||||
await Store.put(StoreKey.deviceId, 'test-device-id');
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
});
|
||||
|
||||
tearDownAll(() {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
mockUpload = MockUploadRepository();
|
||||
mockStorage = MockStorageRepository();
|
||||
mockBackup = MockDriftBackupRepository();
|
||||
mockConnectivity = MockConnectivityApi();
|
||||
mockAssetMedia = MockAssetMediaRepository();
|
||||
mockNativeApi = MockNativeSyncApi();
|
||||
mockLocalAsset = MockDriftLocalAssetRepository();
|
||||
mockEditRevert = MockEditRevertService();
|
||||
mockStack = MockDriftStackRepository();
|
||||
|
||||
sut = ForegroundUploadService(
|
||||
mockUpload,
|
||||
mockStorage,
|
||||
mockBackup,
|
||||
mockConnectivity,
|
||||
mockAssetMedia,
|
||||
mockNativeApi,
|
||||
mockLocalAsset,
|
||||
mockEditRevert,
|
||||
mockStack,
|
||||
);
|
||||
|
||||
tmp = await Directory.systemTemp.createTemp('fg_upload_test');
|
||||
final assetFile = File('${tmp.path}/edited-1.jpg')..writeAsStringSync('edit-bytes');
|
||||
final baseFile = File('${tmp.path}/edited-1_base.jpg')..writeAsStringSync('base-bytes');
|
||||
|
||||
when(() => mockStorage.clearCache()).thenAnswer((_) async {});
|
||||
when(() => mockConnectivity.getCapabilities()).thenAnswer((_) async => [NetworkCapability.unmetered]);
|
||||
|
||||
final entity = MockAssetEntity();
|
||||
when(() => entity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorage.getAssetEntityForAsset(any())).thenAnswer((_) async => entity);
|
||||
when(() => mockStorage.isAssetAvailableLocally(any())).thenAnswer((_) async => true);
|
||||
when(() => mockStorage.getFileForAsset(any())).thenAnswer((_) async => assetFile);
|
||||
when(() => mockAssetMedia.getOriginalFilename(any())).thenAnswer((_) async => 'edited-1.jpg');
|
||||
|
||||
// Not a revert; prior is alive; the edit gate fires with a real base file.
|
||||
when(() => mockEditRevert.tryHandleRevert(any())).thenAnswer((_) async => null);
|
||||
when(() => mockStack.priorState(any())).thenAnswer((_) async => PriorState.live);
|
||||
when(
|
||||
() => mockNativeApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||
).thenAnswer((_) async => BaseResource(path: baseFile.path, sha1: 'base-sha1'));
|
||||
when(
|
||||
() => mockLocalAsset.markSynced(
|
||||
any(),
|
||||
priorRemoteId: any(named: 'priorRemoteId'),
|
||||
syncedChecksum: any(named: 'syncedChecksum'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (tmp.existsSync()) {
|
||||
tmp.deleteSync(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
group('revert short-circuit', () {
|
||||
test('reports the flipped-to base id and skips the upload', () async {
|
||||
final reverted = edited.copyWith(priorRemoteId: 'prior-1', syncedChecksum: 'old-sha1');
|
||||
// The service flipped the cover to a base that isn't the stale pre-flip prior.
|
||||
when(() => mockEditRevert.tryHandleRevert(any())).thenAnswer((_) async => 'base-flip-1');
|
||||
|
||||
final successes = <String>[];
|
||||
await sut.uploadManual([
|
||||
reverted,
|
||||
], callbacks: UploadCallbacks(onSuccess: (_, remoteId) => successes.add(remoteId)));
|
||||
|
||||
// onSuccess carries the id the service flipped to, not asset.priorRemoteId.
|
||||
expect(successes, ['base-flip-1']);
|
||||
verifyNever(
|
||||
() => mockUpload.uploadFile(
|
||||
file: any(named: 'file'),
|
||||
originalFileName: any(named: 'originalFileName'),
|
||||
fields: any(named: 'fields'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
onProgress: any(named: 'onProgress'),
|
||||
logContext: any(named: 'logContext'),
|
||||
),
|
||||
);
|
||||
// The revert service does its own bookkeeping; the upload path stamps nothing.
|
||||
verifyNever(
|
||||
() => mockLocalAsset.markSynced(
|
||||
any(),
|
||||
priorRemoteId: any(named: 'priorRemoteId'),
|
||||
syncedChecksum: any(named: 'syncedChecksum'),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('edit pair base failure', () {
|
||||
test('does not upload the edit or mark synced when the base upload fails', () async {
|
||||
// Base upload fails; the edit upload should never run.
|
||||
when(
|
||||
() => mockUpload.uploadFile(
|
||||
file: any(named: 'file'),
|
||||
originalFileName: any(named: 'originalFileName'),
|
||||
fields: any(named: 'fields'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
onProgress: any(named: 'onProgress'),
|
||||
logContext: any(named: 'logContext'),
|
||||
),
|
||||
).thenAnswer((_) async => UploadResult.error(errorMessage: 'boom', statusCode: 500));
|
||||
|
||||
await sut.uploadManual([edited]);
|
||||
|
||||
// Exactly one upload attempt (the base). The edit must not be uploaded,
|
||||
// and the asset must stay a candidate (no markSynced).
|
||||
verify(
|
||||
() => mockUpload.uploadFile(
|
||||
file: any(named: 'file'),
|
||||
originalFileName: any(named: 'originalFileName'),
|
||||
fields: any(named: 'fields'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
onProgress: any(named: 'onProgress'),
|
||||
logContext: 'baseResource[edited-1]',
|
||||
),
|
||||
).called(1);
|
||||
verifyNever(
|
||||
() => mockUpload.uploadFile(
|
||||
file: any(named: 'file'),
|
||||
originalFileName: any(named: 'originalFileName'),
|
||||
fields: any(named: 'fields'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
onProgress: any(named: 'onProgress'),
|
||||
logContext: 'asset[edited-1]',
|
||||
),
|
||||
);
|
||||
verifyNever(
|
||||
() => mockLocalAsset.markSynced(
|
||||
any(),
|
||||
priorRemoteId: any(named: 'priorRemoteId'),
|
||||
syncedChecksum: any(named: 'syncedChecksum'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('uploads the edit with stackParentId and marks synced when the base succeeds', () async {
|
||||
var uploadCount = 0;
|
||||
when(
|
||||
() => mockUpload.uploadFile(
|
||||
file: any(named: 'file'),
|
||||
originalFileName: any(named: 'originalFileName'),
|
||||
fields: any(named: 'fields'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
onProgress: any(named: 'onProgress'),
|
||||
logContext: any(named: 'logContext'),
|
||||
),
|
||||
).thenAnswer((invocation) async {
|
||||
uploadCount++;
|
||||
// base first → base-remote, then the edit → edit-remote.
|
||||
return UploadResult.success(remoteAssetId: uploadCount == 1 ? 'base-remote' : 'edit-remote');
|
||||
});
|
||||
|
||||
await sut.uploadManual([edited]);
|
||||
|
||||
// The edit upload carries the base's id as stackParentId.
|
||||
final captured =
|
||||
verify(
|
||||
() => mockUpload.uploadFile(
|
||||
file: any(named: 'file'),
|
||||
originalFileName: any(named: 'originalFileName'),
|
||||
fields: captureAny(named: 'fields'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
onProgress: any(named: 'onProgress'),
|
||||
logContext: 'asset[edited-1]',
|
||||
),
|
||||
).captured.single
|
||||
as Map<String, String>;
|
||||
expect(captured['stackParentId'], 'base-remote');
|
||||
|
||||
verify(
|
||||
() => mockLocalAsset.markSynced('edited-1', priorRemoteId: 'edit-remote', syncedChecksum: 'edited-sha1'),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('dead stack parent', () {
|
||||
// Stamped prior is live in the local db, so the edit absorbs into it — but the
|
||||
// server may have already lost the row, answering 400 on the stack attempt.
|
||||
final stamped = edited.copyWith(priorRemoteId: 'prior-1', syncedChecksum: 'old-sha1');
|
||||
|
||||
setUp(() {
|
||||
when(() => mockStack.priorState('prior-1')).thenAnswer((_) async => PriorState.live);
|
||||
when(() => mockLocalAsset.clearSyncStamps(any())).thenAnswer((_) async {});
|
||||
});
|
||||
|
||||
test('a stale-prior 400 on the main upload clears the sync stamps', () async {
|
||||
when(
|
||||
() => mockUpload.uploadFile(
|
||||
file: any(named: 'file'),
|
||||
originalFileName: any(named: 'originalFileName'),
|
||||
fields: any(named: 'fields'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
onProgress: any(named: 'onProgress'),
|
||||
logContext: any(named: 'logContext'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => UploadResult.error(
|
||||
errorMessage: 'Bad request: Cannot stack onto a trashed or missing asset',
|
||||
statusCode: 400,
|
||||
),
|
||||
);
|
||||
|
||||
await sut.uploadManual([stamped]);
|
||||
|
||||
verify(() => mockLocalAsset.clearSyncStamps('edited-1')).called(1);
|
||||
});
|
||||
|
||||
test('an unrelated upload error leaves the stamps alone', () async {
|
||||
when(
|
||||
() => mockUpload.uploadFile(
|
||||
file: any(named: 'file'),
|
||||
originalFileName: any(named: 'originalFileName'),
|
||||
fields: any(named: 'fields'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
onProgress: any(named: 'onProgress'),
|
||||
logContext: any(named: 'logContext'),
|
||||
),
|
||||
).thenAnswer((_) async => UploadResult.error(errorMessage: 'boom', statusCode: 500));
|
||||
|
||||
await sut.uploadManual([stamped]);
|
||||
|
||||
verifyNever(() => mockLocalAsset.clearSyncStamps(any()));
|
||||
});
|
||||
});
|
||||
|
||||
group('android', () {
|
||||
test('a successful upload does not stamp the sync columns', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.iOS);
|
||||
|
||||
when(
|
||||
() => mockUpload.uploadFile(
|
||||
file: any(named: 'file'),
|
||||
originalFileName: any(named: 'originalFileName'),
|
||||
fields: any(named: 'fields'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
onProgress: any(named: 'onProgress'),
|
||||
logContext: any(named: 'logContext'),
|
||||
),
|
||||
).thenAnswer((_) async => UploadResult.success(remoteAssetId: 'remote-1'));
|
||||
|
||||
final successes = <String>[];
|
||||
await sut.uploadManual([edited], callbacks: UploadCallbacks(onSuccess: (_, remoteId) => successes.add(remoteId)));
|
||||
|
||||
expect(successes, ['remote-1']);
|
||||
// Edit stacking is iOS-only: no base read, no stamps.
|
||||
verifyNever(() => mockNativeApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||
verifyNever(
|
||||
() => mockLocalAsset.markSynced(
|
||||
any(),
|
||||
priorRemoteId: any(named: 'priorRemoteId'),
|
||||
syncedChecksum: any(named: 'syncedChecksum'),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('live photo edit pair', () {
|
||||
final liveAsset = LocalAsset(
|
||||
id: 'live-1',
|
||||
name: 'live-1.heic',
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2025, 1, 1, 12),
|
||||
updatedAt: DateTime(2025, 1, 1, 12),
|
||||
playbackStyle: AssetPlaybackStyle.livePhoto,
|
||||
isEdited: false,
|
||||
checksum: 'edit-still-sha1',
|
||||
// cloudId so the metadata field appears on the still uploads.
|
||||
cloudId: 'cloud-live-1',
|
||||
adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30),
|
||||
);
|
||||
|
||||
late File baseStillFile;
|
||||
late File baseVideoFile;
|
||||
late List<String> uploadOrder;
|
||||
late Map<String, Map<String, String>> fieldsByHop;
|
||||
late List<String> errors;
|
||||
|
||||
setUp(() {
|
||||
final editStillFile = File('${tmp.path}/live-1.heic')..writeAsStringSync('edit-still-bytes');
|
||||
final editMotionFile = File('${tmp.path}/live-1_motion.mov')..writeAsStringSync('edit-motion-bytes');
|
||||
baseStillFile = File('${tmp.path}/live-1_base.heic')..writeAsStringSync('base-still-bytes');
|
||||
baseVideoFile = File('${tmp.path}/live-1_base.mov')..writeAsStringSync('base-video-bytes');
|
||||
|
||||
uploadOrder = [];
|
||||
fieldsByHop = {};
|
||||
errors = [];
|
||||
|
||||
final entity = MockAssetEntity();
|
||||
when(() => entity.isLivePhoto).thenReturn(true);
|
||||
when(() => mockStorage.getAssetEntityForAsset(any())).thenAnswer((_) async => entity);
|
||||
when(() => mockStorage.getFileForAsset(any())).thenAnswer((_) async => editStillFile);
|
||||
when(() => mockStorage.getMotionFileForAsset(any())).thenAnswer((_) async => editMotionFile);
|
||||
when(() => mockAssetMedia.getOriginalFilename(any())).thenAnswer((_) async => 'live-1.heic');
|
||||
when(
|
||||
() => mockNativeApi.getBaseLivePhoto(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||
).thenAnswer(
|
||||
(_) async => BaseLivePhoto(
|
||||
still: BaseResource(path: baseStillFile.path, sha1: 'original-sha1'),
|
||||
video: BaseResource(path: baseVideoFile.path, sha1: 'original-video-sha1'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
// Sequences uploads by logContext: records order + a snapshot of the fields,
|
||||
// succeeds with '<hop>-remote' unless the hop has an override result.
|
||||
void stubUploads({Map<String, UploadResult> overrides = const {}}) {
|
||||
when(
|
||||
() => mockUpload.uploadFile(
|
||||
file: any(named: 'file'),
|
||||
originalFileName: any(named: 'originalFileName'),
|
||||
fields: any(named: 'fields'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
onProgress: any(named: 'onProgress'),
|
||||
logContext: any(named: 'logContext'),
|
||||
),
|
||||
).thenAnswer((invocation) async {
|
||||
final logContext = invocation.namedArguments[#logContext] as String;
|
||||
final hop = logContext.split('[').first;
|
||||
uploadOrder.add(logContext);
|
||||
fieldsByHop[hop] = Map.of(invocation.namedArguments[#fields] as Map<String, String>);
|
||||
return overrides[hop] ?? UploadResult.success(remoteAssetId: '$hop-remote');
|
||||
});
|
||||
}
|
||||
|
||||
UploadCallbacks callbacks() => UploadCallbacks(onError: (_, message) => errors.add(message));
|
||||
|
||||
test('uploads the base pair, then motion, then the edit with isolated fields', () async {
|
||||
stubUploads();
|
||||
|
||||
await sut.uploadManual([liveAsset], callbacks: callbacks());
|
||||
|
||||
expect(uploadOrder, [
|
||||
'baseLiveVideo[live-1]',
|
||||
'baseLiveStill[live-1]',
|
||||
'livePhotoVideo[live-1]',
|
||||
'asset[live-1]',
|
||||
]);
|
||||
|
||||
// Both motion videos upload hidden; both stills stay on the timeline. The
|
||||
// edit's motion never inherits the stack parent resolved just before it.
|
||||
final editVideoFields = fieldsByHop['livePhotoVideo']!;
|
||||
expect(editVideoFields['visibility'], 'hidden');
|
||||
expect(editVideoFields.containsKey('stackParentId'), isFalse);
|
||||
final baseVideoFields = fieldsByHop['baseLiveVideo']!;
|
||||
expect(baseVideoFields['visibility'], 'hidden');
|
||||
expect(baseVideoFields.containsKey('livePhotoVideoId'), isFalse);
|
||||
expect(baseVideoFields.containsKey('stackParentId'), isFalse);
|
||||
|
||||
// The base still pairs with the base video and stays free of the edit's timestamp.
|
||||
final baseStillFields = fieldsByHop['baseLiveStill']!;
|
||||
expect(baseStillFields['livePhotoVideoId'], 'baseLiveVideo-remote');
|
||||
expect(baseStillFields.containsKey('stackParentId'), isFalse);
|
||||
expect(baseStillFields.containsKey('visibility'), isFalse);
|
||||
expect(baseStillFields['metadata'], isNotNull);
|
||||
expect(baseStillFields['metadata'], isNot(contains('adjustmentTime')));
|
||||
|
||||
// The edit keeps its own motion id, stacks onto the base still, carries the edit time, stays visible.
|
||||
final mainFields = fieldsByHop['asset']!;
|
||||
expect(mainFields['livePhotoVideoId'], 'livePhotoVideo-remote');
|
||||
expect(mainFields['stackParentId'], 'baseLiveStill-remote');
|
||||
expect(mainFields.containsKey('visibility'), isFalse);
|
||||
expect(mainFields['metadata'], contains('adjustmentTime'));
|
||||
|
||||
verifyInOrder([
|
||||
() => mockLocalAsset.markSynced('live-1', priorRemoteId: 'baseLiveStill-remote', syncedChecksum: null),
|
||||
() => mockLocalAsset.markSynced('live-1', priorRemoteId: 'asset-remote', syncedChecksum: 'edit-still-sha1'),
|
||||
]);
|
||||
expect(errors, isEmpty);
|
||||
expect(baseStillFile.existsSync(), isFalse);
|
||||
expect(baseVideoFile.existsSync(), isFalse);
|
||||
});
|
||||
|
||||
test('base video failure stops the chain and keeps the pair a candidate', () async {
|
||||
stubUploads(overrides: {'baseLiveVideo': UploadResult.error(errorMessage: 'base video boom', statusCode: 500)});
|
||||
|
||||
await sut.uploadManual([liveAsset], callbacks: callbacks());
|
||||
|
||||
// The pair resolves before any file is materialized, so a failed base burns
|
||||
// neither a motion upload nor a file read.
|
||||
expect(uploadOrder, ['baseLiveVideo[live-1]']);
|
||||
verifyNever(() => mockStorage.isAssetAvailableLocally(any()));
|
||||
verifyNever(() => mockStorage.getFileForAsset(any()));
|
||||
verifyNever(() => mockStorage.getMotionFileForAsset(any()));
|
||||
verifyNever(
|
||||
() => mockLocalAsset.markSynced(
|
||||
any(),
|
||||
priorRemoteId: any(named: 'priorRemoteId'),
|
||||
syncedChecksum: any(named: 'syncedChecksum'),
|
||||
),
|
||||
);
|
||||
expect(errors, ['base video boom']);
|
||||
expect(baseStillFile.existsSync(), isFalse);
|
||||
expect(baseVideoFile.existsSync(), isFalse);
|
||||
});
|
||||
|
||||
test('base still failure after video success stops the chain', () async {
|
||||
stubUploads(overrides: {'baseLiveStill': UploadResult.error(errorMessage: 'base still boom', statusCode: 500)});
|
||||
|
||||
await sut.uploadManual([liveAsset], callbacks: callbacks());
|
||||
|
||||
expect(uploadOrder, ['baseLiveVideo[live-1]', 'baseLiveStill[live-1]']);
|
||||
verifyNever(
|
||||
() => mockLocalAsset.markSynced(
|
||||
any(),
|
||||
priorRemoteId: any(named: 'priorRemoteId'),
|
||||
syncedChecksum: any(named: 'syncedChecksum'),
|
||||
),
|
||||
);
|
||||
expect(errors, ['base still boom']);
|
||||
expect(baseStillFile.existsSync(), isFalse);
|
||||
expect(baseVideoFile.existsSync(), isFalse);
|
||||
});
|
||||
|
||||
test('degrades to a still-only base when the original has no paired video', () async {
|
||||
when(
|
||||
() => mockNativeApi.getBaseLivePhoto(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||
).thenAnswer(
|
||||
(_) async => BaseLivePhoto(
|
||||
still: BaseResource(path: baseStillFile.path, sha1: 'original-sha1'),
|
||||
),
|
||||
);
|
||||
stubUploads();
|
||||
|
||||
await sut.uploadManual([liveAsset], callbacks: callbacks());
|
||||
|
||||
expect(uploadOrder, ['baseLiveStill[live-1]', 'livePhotoVideo[live-1]', 'asset[live-1]']);
|
||||
|
||||
final baseStillFields = fieldsByHop['baseLiveStill']!;
|
||||
expect(baseStillFields.containsKey('livePhotoVideoId'), isFalse);
|
||||
|
||||
// The stack still forms onto the lone base still.
|
||||
expect(fieldsByHop['asset']!['stackParentId'], 'baseLiveStill-remote');
|
||||
verify(
|
||||
() => mockLocalAsset.markSynced('live-1', priorRemoteId: 'asset-remote', syncedChecksum: 'edit-still-sha1'),
|
||||
).called(1);
|
||||
expect(errors, isEmpty);
|
||||
expect(baseStillFile.existsSync(), isFalse);
|
||||
});
|
||||
|
||||
test('cancellation on the base hop aborts the run without an error callback', () async {
|
||||
stubUploads(overrides: {'baseLiveVideo': UploadResult.cancelled()});
|
||||
|
||||
await sut.uploadManual([liveAsset], callbacks: callbacks());
|
||||
|
||||
expect(uploadOrder, ['baseLiveVideo[live-1]']);
|
||||
expect(sut.shouldAbortUpload, isTrue);
|
||||
expect(errors, isEmpty);
|
||||
verifyNever(
|
||||
() => mockLocalAsset.markSynced(
|
||||
any(),
|
||||
priorRemoteId: any(named: 'priorRemoteId'),
|
||||
syncedChecksum: any(named: 'syncedChecksum'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('quota error on the base hop aborts the run and surfaces the error', () async {
|
||||
stubUploads(
|
||||
overrides: {'baseLiveVideo': UploadResult.error(errorMessage: 'Quota has been exceeded!', statusCode: 403)},
|
||||
);
|
||||
|
||||
await sut.uploadManual([liveAsset], callbacks: callbacks());
|
||||
|
||||
expect(uploadOrder, ['baseLiveVideo[live-1]']);
|
||||
expect(sut.shouldAbortUpload, isTrue);
|
||||
expect(errors, ['Quota has been exceeded!']);
|
||||
});
|
||||
|
||||
test('a trashed prior defers a manual upload, telling the user and materializing nothing', () async {
|
||||
stubUploads();
|
||||
when(() => mockStack.priorState('prior-1')).thenAnswer((_) async => PriorState.trashed);
|
||||
final deferred = liveAsset.copyWith(priorRemoteId: 'prior-1', syncedChecksum: 'stale-sha1');
|
||||
|
||||
await sut.uploadManual([deferred], callbacks: callbacks());
|
||||
|
||||
expect(uploadOrder, isEmpty);
|
||||
// No localization in tests, so the raw key comes through.
|
||||
expect(errors, ['upload_deferred_edit_pair']);
|
||||
expect(sut.shouldAbortUpload, isFalse);
|
||||
// Resolution happens before any file work, so a defer downloads nothing.
|
||||
verifyNever(() => mockStorage.isAssetAvailableLocally(any()));
|
||||
verifyNever(() => mockStorage.getFileForAsset(any()));
|
||||
verifyNever(() => mockStorage.getMotionFileForAsset(any()));
|
||||
verifyNever(() => mockStorage.loadFileFromCloud(any(), progressHandler: any(named: 'progressHandler')));
|
||||
verifyNever(() => mockStorage.loadMotionFileFromCloud(any(), progressHandler: any(named: 'progressHandler')));
|
||||
verifyNever(
|
||||
() => mockLocalAsset.markSynced(
|
||||
any(),
|
||||
priorRemoteId: any(named: 'priorRemoteId'),
|
||||
syncedChecksum: any(named: 'syncedChecksum'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('the same defer stays silent on the auto-candidates path', () async {
|
||||
stubUploads();
|
||||
when(() => mockStack.priorState('prior-1')).thenAnswer((_) async => PriorState.trashed);
|
||||
final deferred = liveAsset.copyWith(priorRemoteId: 'prior-1', syncedChecksum: 'stale-sha1');
|
||||
when(() => mockBackup.getCandidates(any())).thenAnswer((_) async => [deferred]);
|
||||
|
||||
await sut.uploadCandidates('user-1', Completer<void>(), callbacks: callbacks());
|
||||
|
||||
expect(uploadOrder, isEmpty);
|
||||
expect(errors, isEmpty);
|
||||
expect(sut.shouldAbortUpload, isFalse);
|
||||
verifyNever(() => mockStorage.isAssetAvailableLocally(any()));
|
||||
verifyNever(() => mockStorage.getFileForAsset(any()));
|
||||
});
|
||||
|
||||
test('cancellation on the motion hop aborts before the main upload', () async {
|
||||
stubUploads(overrides: {'livePhotoVideo': UploadResult.cancelled()});
|
||||
|
||||
await sut.uploadManual([liveAsset], callbacks: callbacks());
|
||||
|
||||
expect(uploadOrder, ['baseLiveVideo[live-1]', 'baseLiveStill[live-1]', 'livePhotoVideo[live-1]']);
|
||||
expect(sut.shouldAbortUpload, isTrue);
|
||||
expect(errors, isEmpty);
|
||||
// Only the base stamp lands; the edit never marks synced.
|
||||
verifyNever(
|
||||
() => mockLocalAsset.markSynced(
|
||||
any(),
|
||||
priorRemoteId: any(named: 'priorRemoteId'),
|
||||
syncedChecksum: 'edit-still-sha1',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('quota error on the motion hop aborts and surfaces the error', () async {
|
||||
stubUploads(
|
||||
overrides: {'livePhotoVideo': UploadResult.error(errorMessage: 'Quota has been exceeded!', statusCode: 403)},
|
||||
);
|
||||
|
||||
await sut.uploadManual([liveAsset], callbacks: callbacks());
|
||||
|
||||
expect(uploadOrder, ['baseLiveVideo[live-1]', 'baseLiveStill[live-1]', 'livePhotoVideo[live-1]']);
|
||||
expect(sut.shouldAbortUpload, isTrue);
|
||||
expect(errors, ['Quota has been exceeded!']);
|
||||
});
|
||||
|
||||
test('a non-quota motion failure falls through to a main upload without livePhotoVideoId', () async {
|
||||
stubUploads(overrides: {'livePhotoVideo': UploadResult.error(errorMessage: 'motion boom', statusCode: 500)});
|
||||
|
||||
await sut.uploadManual([liveAsset], callbacks: callbacks());
|
||||
|
||||
expect(uploadOrder, [
|
||||
'baseLiveVideo[live-1]',
|
||||
'baseLiveStill[live-1]',
|
||||
'livePhotoVideo[live-1]',
|
||||
'asset[live-1]',
|
||||
]);
|
||||
expect(sut.shouldAbortUpload, isFalse);
|
||||
expect(errors, isEmpty);
|
||||
|
||||
final mainFields = fieldsByHop['asset']!;
|
||||
expect(mainFields.containsKey('livePhotoVideoId'), isFalse);
|
||||
expect(mainFields['stackParentId'], 'baseLiveStill-remote');
|
||||
verify(
|
||||
() => mockLocalAsset.markSynced('live-1', priorRemoteId: 'asset-remote', syncedChecksum: 'edit-still-sha1'),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('burst member', () {
|
||||
// A non-representative burst frame: photo_manager can't resolve it, so it
|
||||
// streams the natively-read rendition and stacks under the rep's anchor.
|
||||
final member = LocalAsset(
|
||||
id: 'member-1',
|
||||
name: 'member-1.heic',
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2025, 1, 1, 12),
|
||||
updatedAt: DateTime(2025, 1, 1, 12),
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
checksum: 'member-sha1',
|
||||
burstId: 'burst-1',
|
||||
);
|
||||
|
||||
late File memberFile;
|
||||
|
||||
UploadResult stubbedResult = UploadResult.success(remoteAssetId: 'member-remote');
|
||||
|
||||
setUp(() {
|
||||
stubbedResult = UploadResult.success(remoteAssetId: 'member-remote');
|
||||
memberFile = File('${tmp.path}/member-1.heic')..writeAsStringSync('member-bytes');
|
||||
when(
|
||||
() => mockNativeApi.getCurrentResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||
).thenAnswer((_) async => BaseResource(path: memberFile.path, sha1: 'member-sha1'));
|
||||
when(() => mockAssetMedia.getOriginalFilename(any())).thenAnswer((_) async => 'member-1.heic');
|
||||
when(() => mockLocalAsset.burstHasRepresentative(any())).thenAnswer((_) async => false);
|
||||
when(
|
||||
() => mockUpload.uploadFile(
|
||||
file: any(named: 'file'),
|
||||
originalFileName: any(named: 'originalFileName'),
|
||||
fields: any(named: 'fields'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
onProgress: any(named: 'onProgress'),
|
||||
logContext: any(named: 'logContext'),
|
||||
),
|
||||
).thenAnswer((_) async => stubbedResult);
|
||||
});
|
||||
|
||||
Map<String, String> capturedFields() =>
|
||||
verify(
|
||||
() => mockUpload.uploadFile(
|
||||
file: any(named: 'file'),
|
||||
originalFileName: any(named: 'originalFileName'),
|
||||
fields: captureAny(named: 'fields'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
onProgress: any(named: 'onProgress'),
|
||||
logContext: any(named: 'logContext'),
|
||||
),
|
||||
).captured.single
|
||||
as Map<String, String>;
|
||||
|
||||
test('stacks under the anchor with keepPrimary and marks synced', () async {
|
||||
when(
|
||||
() => mockLocalAsset.getBurstParentRemoteId(any(), ownerId: any(named: 'ownerId')),
|
||||
).thenAnswer((_) async => 'anchor-remote');
|
||||
|
||||
final successes = <String>[];
|
||||
await sut.uploadManual([member], callbacks: UploadCallbacks(onSuccess: (_, remoteId) => successes.add(remoteId)));
|
||||
|
||||
final fields = capturedFields();
|
||||
expect(fields['stackParentId'], 'anchor-remote');
|
||||
expect(fields['keepPrimary'], 'true');
|
||||
expect(successes, ['member-remote']);
|
||||
verify(
|
||||
() => mockLocalAsset.markSynced('member-1', priorRemoteId: 'member-remote', syncedChecksum: 'member-sha1'),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('gates (no upload) when the rep exists but has not uploaded yet', () async {
|
||||
when(
|
||||
() => mockLocalAsset.getBurstParentRemoteId(any(), ownerId: any(named: 'ownerId')),
|
||||
).thenAnswer((_) async => null);
|
||||
when(() => mockLocalAsset.burstHasRepresentative(any())).thenAnswer((_) async => true);
|
||||
|
||||
await sut.uploadManual([member]);
|
||||
|
||||
verifyNever(
|
||||
() => mockUpload.uploadFile(
|
||||
file: any(named: 'file'),
|
||||
originalFileName: any(named: 'originalFileName'),
|
||||
fields: any(named: 'fields'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
onProgress: any(named: 'onProgress'),
|
||||
logContext: any(named: 'logContext'),
|
||||
),
|
||||
);
|
||||
verifyNever(
|
||||
() => mockLocalAsset.markSynced(
|
||||
any(),
|
||||
priorRemoteId: any(named: 'priorRemoteId'),
|
||||
syncedChecksum: any(named: 'syncedChecksum'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('uploads standalone (no stack fields) for a rep-less group', () async {
|
||||
// No anchor and no representative (Keep Everything / re-pick): never gate,
|
||||
// upload the frame on its own.
|
||||
when(
|
||||
() => mockLocalAsset.getBurstParentRemoteId(any(), ownerId: any(named: 'ownerId')),
|
||||
).thenAnswer((_) async => null);
|
||||
when(() => mockLocalAsset.burstHasRepresentative(any())).thenAnswer((_) async => false);
|
||||
|
||||
final successes = <String>[];
|
||||
await sut.uploadManual([member], callbacks: UploadCallbacks(onSuccess: (_, remoteId) => successes.add(remoteId)));
|
||||
|
||||
final fields = capturedFields();
|
||||
expect(fields.containsKey('stackParentId'), isFalse);
|
||||
expect(fields.containsKey('keepPrimary'), isFalse);
|
||||
expect(successes, ['member-remote']);
|
||||
});
|
||||
|
||||
test('reports an error and skips the upload when the rendition is gone', () async {
|
||||
when(
|
||||
() => mockLocalAsset.getBurstParentRemoteId(any(), ownerId: any(named: 'ownerId')),
|
||||
).thenAnswer((_) async => 'anchor-remote');
|
||||
when(
|
||||
() => mockNativeApi.getCurrentResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||
).thenAnswer((_) async => null);
|
||||
|
||||
final errors = <String>[];
|
||||
await sut.uploadManual([member], callbacks: UploadCallbacks(onError: (_, msg) => errors.add(msg)));
|
||||
|
||||
expect(errors, hasLength(1));
|
||||
verifyNever(
|
||||
() => mockUpload.uploadFile(
|
||||
file: any(named: 'file'),
|
||||
originalFileName: any(named: 'originalFileName'),
|
||||
fields: any(named: 'fields'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
onProgress: any(named: 'onProgress'),
|
||||
logContext: any(named: 'logContext'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('aborts the batch on cancellation', () async {
|
||||
when(
|
||||
() => mockLocalAsset.getBurstParentRemoteId(any(), ownerId: any(named: 'ownerId')),
|
||||
).thenAnswer((_) async => 'anchor-remote');
|
||||
stubbedResult = UploadResult.cancelled();
|
||||
|
||||
final successes = <String>[];
|
||||
await sut.uploadManual([member], callbacks: UploadCallbacks(onSuccess: (_, remoteId) => successes.add(remoteId)));
|
||||
|
||||
expect(sut.shouldAbortUpload, isTrue);
|
||||
expect(successes, isEmpty);
|
||||
verifyNever(
|
||||
() => mockLocalAsset.markSynced(
|
||||
any(),
|
||||
priorRemoteId: any(named: 'priorRemoteId'),
|
||||
syncedChecksum: any(named: 'syncedChecksum'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('surfaces a quota error and aborts the batch', () async {
|
||||
when(
|
||||
() => mockLocalAsset.getBurstParentRemoteId(any(), ownerId: any(named: 'ownerId')),
|
||||
).thenAnswer((_) async => 'anchor-remote');
|
||||
stubbedResult = UploadResult.error(errorMessage: 'Quota has been exceeded!', statusCode: 413);
|
||||
|
||||
final errors = <String>[];
|
||||
await sut.uploadManual([member], callbacks: UploadCallbacks(onError: (_, msg) => errors.add(msg)));
|
||||
|
||||
expect(errors, ['Quota has been exceeded!']);
|
||||
expect(sut.shouldAbortUpload, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:mocktail/mocktail.dart' as mocktail;
|
||||
|
||||
import '../domain/service.mock.dart';
|
||||
import '../infrastructure/repository.mock.dart';
|
||||
import '../repository.mocks.dart';
|
||||
|
||||
void _registerFallbacks() {
|
||||
mocktail.registerFallbackValue(LocalAlbum(id: '', name: '', updatedAt: DateTime.now()));
|
||||
@@ -24,6 +25,8 @@ class RepositoryMocks {
|
||||
final localAlbum = MockLocalAlbumRepository();
|
||||
final localAsset = MockDriftLocalAssetRepository();
|
||||
final trashedAsset = MockTrashedLocalAssetRepository();
|
||||
final stack = MockDriftStackRepository();
|
||||
final assetApi = MockAssetApiRepository();
|
||||
|
||||
final nativeApi = MockNativeSyncApi();
|
||||
|
||||
@@ -35,6 +38,8 @@ class RepositoryMocks {
|
||||
mocktail.reset(localAlbum);
|
||||
mocktail.reset(localAsset);
|
||||
mocktail.reset(trashedAsset);
|
||||
mocktail.reset(stack);
|
||||
mocktail.reset(assetApi);
|
||||
mocktail.reset(nativeApi);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user