Compare commits

..

31 Commits

Author SHA1 Message Date
Alex Tran
03e8f98c2c feat: consider DAR when extracting video dimension 2026-01-16 02:47:57 +00:00
Jason Rasmussen
843d563178 refactor(web): admin page layout (#25281)
* refactor(web): admin page layout

* chore: remove unused props
2026-01-15 18:58:43 -05:00
Min Idzelis
256d62e22d feat: thumbhash improvments for reactive prop updates (#25287) 2026-01-15 18:57:43 -05:00
shenlong
91592aa48e fix(mobile): drop unique constraint on cloud_id (#25291)
fix: drop unique constraint on cloud_id

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-01-15 17:06:29 -06:00
shenlong
2ac113624b chore: remote unused sync_cloud_ids key (#25290)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-01-15 16:56:05 -06:00
renovate[bot]
0052979853 chore(deps): update dependency svelte to v5.46.4 [security] (#25284)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-15 22:10:17 +01:00
renovate[bot]
79b6c4ac70 chore(deps): update dependency @sveltejs/kit to v2.49.5 [security] (#25280)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-15 15:07:26 -05:00
Alex
95eb3e26c3 chore: sidebar spacing (#25278) 2026-01-15 10:35:01 -06:00
Alex
613dc858cb chore: tweak table text size (#25276) 2026-01-15 11:06:34 -05:00
shenlong
2f3fbd7dc5 fix: ignore duplicate cloud ID updates (#25271)
* fix: ignore duplicate remote updates

* update cloudId when any one of the ETag part is mismatched

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-01-15 09:15:56 -06:00
Min Idzelis
80a5444bf4 feat: redesign asset-viewer previous/next and hide when nav not possible (#24903) 2026-01-15 12:55:01 +01:00
Jason Rasmussen
d59ee7d2ae feat(web): immich/ui select component (#25268)
Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-01-14 19:38:13 -06:00
idubnori
7b3a298c6a fix: Swagger UI generates incorrect double-prefixed URLs (/api/api/...) (#25266)
fix: add ignoreGlobalPrefix option to Swagger options
2026-01-14 16:55:17 -06:00
Alex
0a62ec7e29 chore: album option modal styling (#25269)
* chore: album option modal styling

* header action button color
2026-01-14 16:52:33 -06:00
Jason Rasmussen
21802ab5ba fix(server): prevent duplicate metadata items from being sent (#25267) 2026-01-14 16:52:06 -06:00
Daniel Dietzler
56dfdfd033 refactor: album share and options modals (#25212)
* refactor: album share modals

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-01-14 15:18:02 -05:00
Jason Rasmussen
2190921c85 chore: await api key nested modal (#25265) 2026-01-14 14:02:44 -05:00
shenlong
9fa8de7baa feat: add cloud id during native sync (#20418)
* use adjustment time in iOS for hash reset

# Conflicts:
#	mobile/lib/infrastructure/repositories/local_album.repository.dart
#	mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart

* migration

* feat: sync cloudId and eTag on sync

* fixes fixes

* more fixes

* re-sync updated eTags

* add server version check & auto sync cloud ids on compatible servers

* fix test

* remove button from sync status page

* chore: modify for testing

* more changes

* chore: add commas in toString

* use cached provider in splash screen

* read upload service provider to prevent reset

* log errors from fetching cloud id mapping

* WIP: migrate cloud id - debug log

* ignore locked asset update

* bulk update metadata

* change log text

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-01-14 12:34:11 -06:00
Akash Karmakar
ed9448a6ee fix: dark mode appbar color (#24976)
* fix: dark mode appbar color

* update: using scrolledUnderElevation for sufaceTint change

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-01-14 17:47:34 +00:00
Noel S
15224a9ac5 fix(mobile): improve asset transition back to timeline (#24485)
* test

* wip

* fix: indicators popping in due to z height change of hero animation (fade in instead after animation)

* wip

* fix: selection outline changing to transparent before animation finish

* Remove unnecessary changes and follow conventions

* remove accidentally included files

* clean up

* new approach

* detect hero animation.

* wip

* Revert "new approach"

This reverts commit 13919f6d04.

* remove delayed animation

* wip

* wip (need to fix first open not triggering indicator hide)

* fix indicators not hiding on first hero animation

* Add back hiding selection background container

* revert accidental regression

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-01-14 10:40:24 -06:00
Alex
6e00fd92ef chore: use fontWeight for Text component (#25262) 2026-01-14 16:25:30 +00:00
Alex
6fdd1ce41a chore: use font-mono (#25250)
* chore: use font-mono

* chore: override variable
2026-01-14 11:21:48 -05:00
Jason Rasmussen
91d4cd6824 refactor: tables (#25226) 2026-01-14 07:56:09 -05:00
Ben
c7254a0c30 fix(docs): add missing mermaid dependency and configuration (#25247)
* fix(docs): add missing mermaid dependency and configuration

* fix: include pnpm-lock.yaml

* fix: docusaurus config format issue
2026-01-13 23:13:34 -05:00
Jason Rasmussen
38f01a6b7d fix(web): redirect to login (#25254) 2026-01-13 23:11:14 -05:00
Jason Rasmussen
f194a7ea3e fix: migration order (#25249) 2026-01-13 14:47:58 -06:00
Noel S
05a7ba98c1 fix(mobile): prevent system UI from hiding on drag down gesture (#25240)
* fix system ui briefly disappearing

* code style change
2026-01-13 19:40:24 +00:00
Alex
edc513a3df feat(web): 2026 font (#25174)
* feat(web): 2026 font

* chore: docs font

* spacing tweak

* tweak minimum font weight and update ui lib

* small tweaks

* docs: small tweaks

* more tweaks
2026-01-13 18:19:09 +00:00
Yaros
39212a049c feat(web): search albums by description (#25244)
feat: search albums by description
2026-01-13 11:56:59 -06:00
renovate[bot]
9b4f370834 chore(deps): update node.js to v24.13.0 (#25243)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 16:19:10 +00:00
Alex
aba85b036c feat(mobile): 2026 font (#25213) 2026-01-13 09:59:57 -06:00
186 changed files with 5234 additions and 1898 deletions

2
.github/.nvmrc vendored
View File

@@ -1 +1 @@
24.12.0
24.13.0

View File

@@ -1 +1 @@
24.12.0
24.13.0

View File

@@ -69,6 +69,6 @@
"micromatch": "^4.0.8"
},
"volta": {
"node": "24.12.0"
"node": "24.13.0"
}
}

View File

@@ -1 +1 @@
24.12.0
24.13.0

View File

@@ -26,6 +26,12 @@ const config = {
locales: ['en'],
},
// Mermaid diagrams
markdown: {
mermaid: true,
},
themes: ['@docusaurus/theme-mermaid'],
plugins: [
async function myPlugin(context, options) {
return {

View File

@@ -20,6 +20,7 @@
"@docusaurus/core": "~3.9.0",
"@docusaurus/preset-classic": "~3.9.0",
"@docusaurus/theme-common": "~3.9.0",
"@docusaurus/theme-mermaid": "~3.9.0",
"@mdi/js": "^7.3.67",
"@mdi/react": "^1.6.1",
"@mdx-js/react": "^3.0.0",
@@ -57,6 +58,6 @@
"node": ">=20"
},
"volta": {
"node": "24.12.0"
"node": "24.13.0"
}
}

View File

@@ -8,19 +8,19 @@
@tailwind utilities;
@font-face {
font-family: 'Overpass';
src: url('/fonts/overpass/Overpass.ttf') format('truetype-variations');
font-weight: 1 999;
font-family: 'GoogleSans';
src: url('/fonts/GoogleSans/GoogleSans.ttf') format('truetype-variations');
font-weight: 410 900;
font-style: normal;
ascent-override: 106.25%;
size-adjust: 106.25%;
}
@font-face {
font-family: 'Overpass Mono';
src: url('/fonts/overpass/OverpassMono.ttf') format('truetype-variations');
font-weight: 1 999;
font-style: normal;
font-family: 'GoogleSansCode';
src: url('/fonts/GoogleSansCode/GoogleSansCode.ttf') format('truetype-variations');
font-weight: 1 900;
font-style: monospace;
ascent-override: 106.25%;
size-adjust: 106.25%;
}
@@ -37,7 +37,8 @@ img {
/* You can override the default Infima variables here. */
:root {
font-family: 'Overpass', sans-serif;
font-family: 'GoogleSans', sans-serif;
letter-spacing: 0.1px;
--ifm-color-primary: #4250af;
--ifm-color-primary-dark: #4250af;
--ifm-color-primary-darker: #4250af;
@@ -48,6 +49,16 @@ img {
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: 'GoogleSans', sans-serif;
letter-spacing: 0.1px;
}
/* For readability concerns, you should choose a lighter palette in dark mode. */
[data-theme='dark'] {
--ifm-color-primary: #adcbfa;
@@ -71,15 +82,22 @@ div[class^='announcementBar_'] {
padding: 10px 10px 10px 16px;
border-radius: 24px;
margin-right: 16px;
font-weight: 500;
}
.menu__list-item-collapsible {
margin-right: 16px;
border-radius: 24px;
font-weight: 500;
}
.menu__link--active {
font-weight: 500;
font-weight: 600;
}
.table-of-contents__link {
font-size: 14px;
font-weight: 450;
}
/* workaround for version switcher PR 15894 */
@@ -88,13 +106,14 @@ div[class*='navbar__items'] > li:has(a[class*='version-switcher-34ab39']) {
}
code {
font-weight: 600;
font-weight: 500;
font-family: 'GoogleSansCode';
}
.buy-button {
padding: 8px 14px;
border: 1px solid transparent;
font-family: 'Overpass', sans-serif;
font-family: 'GoogleSans', sans-serif;
font-weight: 500;
cursor: pointer;
box-shadow: 0 0 5px 2px rgba(181, 206, 254, 0.4);

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1 +1 @@
24.12.0
24.13.0

View File

@@ -52,6 +52,6 @@
"vitest": "^3.0.0"
},
"volta": {
"node": "24.12.0"
"node": "24.13.0"
}
}

View File

@@ -1,5 +1,5 @@
import { faker } from '@faker-js/faker';
import { test } from '@playwright/test';
import { expect, test } from '@playwright/test';
import {
Changes,
createDefaultTimelineConfig,
@@ -58,6 +58,120 @@ test.describe('asset-viewer', () => {
});
test.describe('/photos/:id', () => {
test('Navigate to next asset via button', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
await page.getByLabel('View next asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
});
test('Navigate to previous asset via button', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
await page.getByLabel('View previous asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
});
test('Navigate to next asset via keyboard (ArrowRight)', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
await page.keyboard.press('ArrowRight');
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
});
test('Navigate to previous asset via keyboard (ArrowLeft)', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
await page.keyboard.press('ArrowLeft');
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
});
test('Navigate forward 5 times via button', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
for (let i = 1; i <= 5; i++) {
await page.getByLabel('View next asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + i].id}`);
}
});
test('Navigate backward 5 times via button', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
for (let i = 1; i <= 5; i++) {
await page.getByLabel('View previous asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index - i]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - i].id}`);
}
});
test('Navigate forward then backward via keyboard', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
// Navigate forward 3 times
for (let i = 1; i <= 3; i++) {
await page.keyboard.press('ArrowRight');
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
}
// Navigate backward 3 times to return to original
for (let i = 2; i >= 0; i--) {
await page.keyboard.press('ArrowLeft');
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
}
// Verify we're back at the original asset
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
});
test('Verify no next button on last asset', async ({ page }) => {
const lastAsset = assets.at(-1)!;
await page.goto(`/photos/${lastAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, lastAsset);
// Verify next button doesn't exist
await expect(page.getByLabel('View next asset')).toHaveCount(0);
});
test('Verify no previous button on first asset', async ({ page }) => {
const firstAsset = assets[0];
await page.goto(`/photos/${firstAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, firstAsset);
// Verify previous button doesn't exist
await expect(page.getByLabel('View previous asset')).toHaveCount(0);
});
test('Delete photo advances to next', async ({ page }) => {
const asset = selectRandom(assets, rng);
await page.goto(`/photos/${asset.id}`);

View File

@@ -3,7 +3,7 @@ import { Page, expect, test } from '@playwright/test';
import { utils } from 'src/utils';
function imageLocator(page: Page) {
return page.getByAltText('Image taken on').locator('visible=true');
return page.getByAltText('Image taken').locator('visible=true');
}
test.describe('Photo Viewer', () => {
let admin: LoginResponseDto;

View File

@@ -1,7 +1,7 @@
experimental_monorepo_root = true
[tools]
node = "24.12.0"
node = "24.13.0"
flutter = "3.35.7"
pnpm = "10.27.0"
terragrunt = "0.93.10"

View File

@@ -252,6 +252,40 @@ data class HashResult (
override fun hashCode(): Int = toList().hashCode()
}
/** Generated class from Pigeon that represents data sent in messages. */
data class CloudIdResult (
val assetId: String,
val error: String? = null,
val cloudId: String? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): CloudIdResult {
val assetId = pigeonVar_list[0] as String
val error = pigeonVar_list[1] as String?
val cloudId = pigeonVar_list[2] as String?
return CloudIdResult(assetId, error, cloudId)
}
}
fun toList(): List<Any?> {
return listOf(
assetId,
error,
cloudId,
)
}
override fun equals(other: Any?): Boolean {
if (other !is CloudIdResult) {
return false
}
if (this === other) {
return true
}
return MessagesPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
private open class MessagesPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
@@ -275,6 +309,11 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
HashResult.fromList(it)
}
}
133.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
CloudIdResult.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
@@ -296,6 +335,10 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
stream.write(132)
writeValue(stream, value.toList())
}
is CloudIdResult -> {
stream.write(133)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
@@ -315,6 +358,7 @@ interface NativeSyncApi {
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
fun cancelHashing()
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
companion object {
/** The codec used by NativeSyncApi. */
@@ -508,6 +552,23 @@ interface NativeSyncApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val assetIdsArg = args[0] as List<String>
val wrapped: List<Any?> = try {
listOf(api.getCloudIdForAssetIds(assetIdsArg))
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}

View File

@@ -14,6 +14,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
@@ -21,7 +22,6 @@ import kotlinx.coroutines.sync.withPermit
import java.io.File
import java.security.MessageDigest
import kotlin.coroutines.cancellation.CancellationException
import kotlin.coroutines.coroutineContext
sealed class AssetResult {
data class ValidAsset(val asset: PlatformAsset, val albumId: String) : AssetResult()
@@ -298,7 +298,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
var bytesRead: Int
val buffer = ByteArray(HASH_BUFFER_SIZE)
while (inputStream.read(buffer).also { bytesRead = it } > 0) {
coroutineContext.ensureActive()
currentCoroutineContext().ensureActive()
digest.update(buffer, 0, bytesRead)
}
} ?: return HashResult(assetId, "Cannot open input stream for asset", null)
@@ -316,4 +316,10 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
hashTask?.cancel()
hashTask = null
}
// This method is only implemented on iOS; on Android, we do not have a concept of cloud IDs
@Suppress("unused", "UNUSED_PARAMETER")
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
return emptyList()
}
}

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -312,6 +312,39 @@ struct HashResult: Hashable {
}
}
/// Generated class from Pigeon that represents data sent in messages.
struct CloudIdResult: Hashable {
var assetId: String
var error: String? = nil
var cloudId: String? = nil
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> CloudIdResult? {
let assetId = pigeonVar_list[0] as! String
let error: String? = nilOrValue(pigeonVar_list[1])
let cloudId: String? = nilOrValue(pigeonVar_list[2])
return CloudIdResult(
assetId: assetId,
error: error,
cloudId: cloudId
)
}
func toList() -> [Any?] {
return [
assetId,
error,
cloudId,
]
}
static func == (lhs: CloudIdResult, rhs: CloudIdResult) -> Bool {
return deepEqualsMessages(lhs.toList(), rhs.toList()) }
func hash(into hasher: inout Hasher) {
deepHashMessages(value: toList(), hasher: &hasher)
}
}
private class MessagesPigeonCodecReader: FlutterStandardReader {
override func readValue(ofType type: UInt8) -> Any? {
switch type {
@@ -323,6 +356,8 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
return SyncDelta.fromList(self.readValue() as! [Any?])
case 132:
return HashResult.fromList(self.readValue() as! [Any?])
case 133:
return CloudIdResult.fromList(self.readValue() as! [Any?])
default:
return super.readValue(ofType: type)
}
@@ -343,6 +378,9 @@ private class MessagesPigeonCodecWriter: FlutterStandardWriter {
} else if let value = value as? HashResult {
super.writeByte(132)
super.writeValue(value.toList())
} else if let value = value as? CloudIdResult {
super.writeByte(133)
super.writeValue(value.toList())
} else {
super.writeValue(value)
}
@@ -377,6 +415,7 @@ protocol NativeSyncApi {
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
func cancelHashing() throws
func getTrashedAssets() throws -> [String: [PlatformAsset]]
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -560,5 +599,22 @@ class NativeSyncApiSetup {
} else {
getTrashedAssetsChannel.setMessageHandler(nil)
}
let getCloudIdForAssetIdsChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getCloudIdForAssetIdsChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let assetIdsArg = args[0] as! [String]
do {
let result = try api.getCloudIdForAssetIds(assetIds: assetIdsArg)
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
getCloudIdForAssetIdsChannel.setMessageHandler(nil)
}
}
}

View File

@@ -19,31 +19,31 @@ struct AssetWrapper: Hashable, Equatable {
class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
static let name = "NativeSyncApi"
static func register(with registrar: any FlutterPluginRegistrar) {
let instance = NativeSyncApiImpl()
NativeSyncApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance)
registrar.publish(instance)
}
func detachFromEngine(for registrar: any FlutterPluginRegistrar) {
super.detachFromEngine()
}
private let defaults: UserDefaults
private let changeTokenKey = "immich:changeToken"
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
private let recoveredAlbumSubType = 1000000219
private var hashTask: Task<Void?, Error>?
private static let hashCancelledCode = "HASH_CANCELLED"
private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil))
init(with defaults: UserDefaults = .standard) {
self.defaults = defaults
}
@available(iOS 16, *)
private func getChangeToken() -> PHPersistentChangeToken? {
guard let data = defaults.data(forKey: changeTokenKey) else {
@@ -51,7 +51,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
}
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: PHPersistentChangeToken.self, from: data)
}
@available(iOS 16, *)
private func saveChangeToken(token: PHPersistentChangeToken) -> Void {
guard let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) else {
@@ -59,18 +59,18 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
}
defaults.set(data, forKey: changeTokenKey)
}
func clearSyncCheckpoint() -> Void {
defaults.removeObject(forKey: changeTokenKey)
}
func checkpointSync() {
guard #available(iOS 16, *) else {
return
}
saveChangeToken(token: PHPhotoLibrary.shared().currentChangeToken)
}
func shouldFullSync() -> Bool {
guard #available(iOS 16, *),
PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized,
@@ -78,36 +78,36 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
// When we do not have access to photo library, older iOS version or No token available, fallback to full sync
return true
}
guard let _ = try? PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) else {
// Cannot fetch persistent changes
return true
}
return false
}
func getAlbums() throws -> [PlatformAlbum] {
var albums: [PlatformAlbum] = []
albumTypes.forEach { type in
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
for i in 0..<collections.count {
let album = collections.object(at: i)
// Ignore recovered album
if(album.assetCollectionSubtype.rawValue == self.recoveredAlbumSubType) {
continue;
}
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
var domainAlbum = PlatformAlbum(
id: album.localIdentifier,
name: album.localizedTitle!,
@@ -115,57 +115,57 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
isCloud: isCloud,
assetCount: Int64(assets.count)
)
if let firstAsset = assets.firstObject {
domainAlbum.updatedAt = firstAsset.modificationDate.map { Int64($0.timeIntervalSince1970) }
}
albums.append(domainAlbum)
}
}
return albums.sorted { $0.id < $1.id }
}
func getMediaChanges() throws -> SyncDelta {
guard #available(iOS 16, *) else {
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil)
}
guard PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized else {
throw PigeonError(code: "NO_AUTH", message: "No photo library access", details: nil)
}
guard let storedToken = getChangeToken() else {
// No token exists, definitely need a full sync
print("MediaManager::getMediaChanges: No token found")
throw PigeonError(code: "NO_TOKEN", message: "No stored change token", details: nil)
}
let currentToken = PHPhotoLibrary.shared().currentChangeToken
if storedToken == currentToken {
return SyncDelta(hasChanges: false, updates: [], deletes: [], assetAlbums: [:])
}
do {
let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken)
var updatedAssets: Set<AssetWrapper> = []
var deletedAssets: Set<String> = []
for change in changes {
guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue }
let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers)
deletedAssets.formUnion(details.deletedLocalIdentifiers)
if (updated.isEmpty) { continue }
let options = PHFetchOptions()
options.includeHiddenAssets = false
let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options)
for i in 0..<result.count {
let asset = result.object(at: i)
// Asset wrapper only uses the id for comparison. Multiple change can contain the same asset, skip duplicate changes
let predicate = PlatformAsset(
id: asset.localIdentifier,
@@ -178,25 +178,25 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
continue
}
let domainAsset = AssetWrapper(with: asset.toPlatformAsset())
updatedAssets.insert(domainAsset)
}
}
let updates = Array(updatedAssets.map { $0.asset })
return SyncDelta(hasChanges: true, updates: updates, deletes: Array(deletedAssets), assetAlbums: buildAssetAlbumsMap(assets: updates))
}
}
private func buildAssetAlbumsMap(assets: Array<PlatformAsset>) -> [String: [String]] {
guard !assets.isEmpty else {
return [:]
}
var albumAssets: [String: [String]] = [:]
for type in albumTypes {
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
collections.enumerateObjects { (album, _, _) in
@@ -211,13 +211,13 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
}
return albumAssets
}
func getAssetIdsForAlbum(albumId: String) throws -> [String] {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else {
return []
}
var ids: [String] = []
let options = PHFetchOptions()
options.includeHiddenAssets = false
@@ -227,13 +227,13 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
}
return ids
}
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else {
return 0
}
let date = NSDate(timeIntervalSince1970: TimeInterval(timestamp))
let options = PHFetchOptions()
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
@@ -241,32 +241,32 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
let assets = getAssetsFromAlbum(in: album, options: options)
return Int64(assets.count)
}
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else {
return []
}
let options = PHFetchOptions()
options.includeHiddenAssets = false
if(updatedTimeCond != nil) {
let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!))
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
}
let result = getAssetsFromAlbum(in: album, options: options)
if(result.count == 0) {
return []
}
var assets: [PlatformAsset] = []
result.enumerateObjects { (asset, _, _) in
assets.append(asset.toPlatformAsset())
}
return assets
}
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) {
if let prevTask = hashTask {
prevTask.cancel()
@@ -284,11 +284,11 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
missingAssetIds.remove(asset.localIdentifier)
assets.append(asset)
}
if Task.isCancelled {
return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
}
await withTaskGroup(of: HashResult?.self) { taskGroup in
var results = [HashResult]()
results.reserveCapacity(assets.count)
@@ -301,28 +301,28 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return await self.hashAsset(asset, allowNetworkAccess: allowNetworkAccess)
}
}
for await result in taskGroup {
guard let result = result else {
return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
}
results.append(result)
}
for missing in missingAssetIds {
results.append(HashResult(assetId: missing, error: "Asset not found in library", hash: nil))
}
return self?.completeWhenActive(for: completion, with: .success(results))
}
}
}
func cancelHashing() {
hashTask?.cancel()
hashTask = nil
}
private func hashAsset(_ asset: PHAsset, allowNetworkAccess: Bool) async -> HashResult? {
class RequestRef {
var id: PHAssetResourceDataRequestID?
@@ -332,21 +332,21 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
if Task.isCancelled {
return nil
}
guard let resource = asset.getResource() else {
return HashResult(assetId: asset.localIdentifier, error: "Cannot get asset resource", hash: nil)
}
if Task.isCancelled {
return nil
}
let options = PHAssetResourceRequestOptions()
options.isNetworkAccessAllowed = allowNetworkAccess
return await withCheckedContinuation { continuation in
var hasher = Insecure.SHA1()
requestRef.id = PHAssetResourceManager.default().requestData(
for: resource,
options: options,
@@ -377,11 +377,11 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
PHAssetResourceManager.default().cancelDataRequest(requestId)
})
}
func getTrashedAssets() throws -> [String: [PlatformAsset]] {
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
}
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> {
// Ensure to actually getting all assets for the Recents album
if (album.assetCollectionSubtype == .smartAlbumUserLibrary) {
@@ -390,4 +390,28 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return PHAsset.fetchAssets(in: album, options: options)
}
}
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult] {
guard #available(iOS 16, *) else {
return assetIds.map { CloudIdResult(assetId: $0) }
}
var mappings: [CloudIdResult] = []
let result = PHPhotoLibrary.shared().cloudIdentifierMappings(forLocalIdentifiers: assetIds)
for (key, value) in result {
switch value {
case .success(let cloudIdentifier):
let cloudId = cloudIdentifier.stringValue
// Ignores invalid cloud ids of the format "GUID:ID:". Valid Ids are of the form "GUID:ID:HASH"
if !cloudId.hasSuffix(":") {
mappings.append(CloudIdResult(assetId: key, cloudId: cloudId))
} else {
mappings.append(CloudIdResult(assetId: key, error: "Incomplete Cloud Id: \(cloudId)"))
}
case .failure(let error):
mappings.append(CloudIdResult(assetId: key, error: "Error getting Cloud Id: \(error.localizedDescription)"))
}
}
return mappings;
}
}

View File

@@ -4,6 +4,8 @@ const int noDbId = -9223372036854775808; // from Isar
const double downloadCompleted = -1;
const double downloadFailed = -2;
const String kMobileMetadataKey = "mobile-app";
// Number of log entries to retain on app start
const int kLogTruncateLimit = 2000;

View File

@@ -11,5 +11,3 @@ enum ActionSource { timeline, viewer }
enum CleanupStep { selectDate, filterOptions, scan, delete }
enum AssetFilterType { all, photosOnly, videosOnly }
enum AssetDateAggregation { start, end }

View File

@@ -51,4 +51,4 @@ const Map<String, Locale> locales = {
const String translationsPath = 'assets/i18n';
const List<Locale> localesNotSupportedByOverpass = [Locale('el', 'GR'), Locale('sr', 'Cyrl')];
const List<Locale> localesNotSupportedByAppFont = [Locale('el', 'GR'), Locale('sr', 'Cyrl')];

View File

@@ -0,0 +1,62 @@
enum RemoteAssetMetadataKey {
mobileApp("mobile-app");
final String key;
const RemoteAssetMetadataKey(this.key);
}
abstract class RemoteAssetMetadataValue {
const RemoteAssetMetadataValue();
Map<String, dynamic> toJson();
}
class RemoteAssetMetadataItem {
final RemoteAssetMetadataKey key;
final RemoteAssetMetadataValue value;
const RemoteAssetMetadataItem({required this.key, required this.value});
Map<String, Object?> toJson() {
return {'key': key.key, 'value': value};
}
}
class RemoteAssetMobileAppMetadata extends RemoteAssetMetadataValue {
final String? cloudId;
final String? createdAt;
final String? adjustmentTime;
final String? latitude;
final String? longitude;
const RemoteAssetMobileAppMetadata({
this.cloudId,
this.createdAt,
this.adjustmentTime,
this.latitude,
this.longitude,
});
@override
Map<String, dynamic> toJson() {
final map = <String, Object?>{};
if (cloudId != null) {
map["iCloudId"] = cloudId;
}
if (createdAt != null) {
map["createdAt"] = createdAt;
}
if (adjustmentTime != null) {
map["adjustmentTime"] = adjustmentTime;
}
if (latitude != null) {
map["latitude"] = latitude;
}
if (longitude != null) {
map["longitude"] = longitude;
}
return map;
}
}

View File

@@ -3,6 +3,7 @@ part of 'base_asset.model.dart';
class LocalAsset extends BaseAsset {
final String id;
final String? remoteAssetId;
final String? cloudId;
final int orientation;
final DateTime? adjustmentTime;
@@ -12,6 +13,7 @@ class LocalAsset extends BaseAsset {
const LocalAsset({
required this.id,
String? remoteId,
this.cloudId,
required super.name,
super.checksum,
required super.type,
@@ -53,12 +55,14 @@ class LocalAsset extends BaseAsset {
width: ${width ?? "<NA>"},
height: ${height ?? "<NA>"},
durationInSeconds: ${durationInSeconds ?? "<NA>"},
remoteId: ${remoteId ?? "<NA>"}
remoteId: ${remoteId ?? "<NA>"},
cloudId: ${cloudId ?? "<NA>"},
checksum: ${checksum ?? "<NA>"},
isFavorite: $isFavorite,
orientation: $orientation,
adjustmentTime: $adjustmentTime,
latitude: ${latitude ?? "<NA>"},
longitude: ${longitude ?? "<NA>"},
orientation: $orientation,
adjustmentTime: $adjustmentTime,
latitude: ${latitude ?? "<NA>"},
longitude: ${longitude ?? "<NA>"},
}''';
}
@@ -69,6 +73,7 @@ class LocalAsset extends BaseAsset {
if (identical(this, other)) return true;
return super == other &&
id == other.id &&
cloudId == other.cloudId &&
orientation == other.orientation &&
adjustmentTime == other.adjustmentTime &&
latitude == other.latitude &&
@@ -88,6 +93,7 @@ class LocalAsset extends BaseAsset {
LocalAsset copyWith({
String? id,
String? remoteId,
String? cloudId,
String? name,
String? checksum,
AssetType? type,
@@ -105,6 +111,7 @@ class LocalAsset extends BaseAsset {
return LocalAsset(
id: id ?? this.id,
remoteId: remoteId ?? this.remoteId,
cloudId: cloudId ?? this.cloudId,
name: name ?? this.name,
checksum: checksum ?? this.checksum,
type: type ?? this.type,

View File

@@ -40,6 +40,9 @@ class HashService {
_log.info("Starting hashing of assets");
final Stopwatch stopwatch = Stopwatch()..start();
try {
// Migrate hashes from cloud ID to local ID so we don't have to re-hash them
await _migrateHashes();
// Sorted by backupSelection followed by isCloud
final localAlbums = await _localAlbumRepository.getBackupAlbums();
@@ -75,6 +78,15 @@ class HashService {
_log.info("Hashing took - ${stopwatch.elapsedMilliseconds}ms");
}
Future<void> _migrateHashes() async {
final hashMappings = await _localAssetRepository.getHashMappingFromCloudId();
if (hashMappings.isEmpty) {
return;
}
await _localAssetRepository.updateHashes(hashMappings);
}
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
/// with hash for those that were successfully hashed. Hashes are looked up in a table
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.

View File

@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.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/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
@@ -18,6 +19,7 @@ import 'package:logging/logging.dart';
class LocalSyncService {
final DriftLocalAlbumRepository _localAlbumRepository;
final DriftLocalAssetRepository _localAssetRepository;
final NativeSyncApi _nativeSyncApi;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final LocalFilesManagerRepository _localFilesManager;
@@ -26,11 +28,13 @@ class LocalSyncService {
LocalSyncService({
required DriftLocalAlbumRepository localAlbumRepository,
required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required LocalFilesManagerRepository localFilesManager,
required StorageRepository storageRepository,
required NativeSyncApi nativeSyncApi,
}) : _localAlbumRepository = localAlbumRepository,
_localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository,
_localFilesManager = localFilesManager,
_storageRepository = storageRepository,
@@ -47,6 +51,12 @@ class LocalSyncService {
_log.warning("syncTrashedAssets cannot proceed because MANAGE_MEDIA permission is missing");
}
}
if (CurrentPlatform.isIOS) {
final assets = await _localAssetRepository.getEmptyCloudIdAssets();
await _mapIosCloudIds(assets);
}
if (full || await _nativeSyncApi.shouldFullSync()) {
_log.fine("Full sync request from ${full ? "user" : "native"}");
return await fullSync();
@@ -63,8 +73,9 @@ class LocalSyncService {
final deviceAlbums = await _nativeSyncApi.getAlbums();
await _localAlbumRepository.updateAll(deviceAlbums.toLocalAlbums());
final newAssets = delta.updates.toLocalAssets();
await _localAlbumRepository.processDelta(
updates: delta.updates.toLocalAssets(),
updates: newAssets,
deletes: delta.deletes,
assetAlbums: delta.assetAlbums,
);
@@ -92,6 +103,8 @@ class LocalSyncService {
}
await updateAlbum(dbAlbum, album);
}
await _mapIosCloudIds(newAssets);
}
await _nativeSyncApi.checkpointSync();
} catch (e, s) {
@@ -130,9 +143,12 @@ class LocalSyncService {
try {
_log.fine("Adding device album ${album.name}");
final assets = album.assetCount > 0 ? await _nativeSyncApi.getAssetsForAlbum(album.id) : <PlatformAsset>[];
final assets = album.assetCount > 0
? await _nativeSyncApi.getAssetsForAlbum(album.id).then((a) => a.toLocalAssets())
: <LocalAsset>[];
await _localAlbumRepository.upsert(album, toUpsert: assets.toLocalAssets());
await _localAlbumRepository.upsert(album, toUpsert: assets);
await _mapIosCloudIds(assets);
_log.fine("Successfully added device album ${album.name}");
} catch (e, s) {
_log.warning("Error while adding device album", e, s);
@@ -202,13 +218,16 @@ class LocalSyncService {
return false;
}
final newAssets = await _nativeSyncApi.getAssetsForAlbum(deviceAlbum.id, updatedTimeCond: updatedTime);
final newAssets = await _nativeSyncApi
.getAssetsForAlbum(deviceAlbum.id, updatedTimeCond: updatedTime)
.then((a) => a.toLocalAssets());
await _localAlbumRepository.upsert(
deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection),
toUpsert: newAssets.toLocalAssets(),
toUpsert: newAssets,
);
await _mapIosCloudIds(newAssets);
return true;
} catch (e, s) {
_log.warning("Error on fast syncing local album: ${dbAlbum.name}", e, s);
@@ -240,6 +259,7 @@ class LocalSyncService {
if (dbAlbum.assetCount == 0) {
_log.fine("Device album ${deviceAlbum.name} is empty. Adding assets to DB.");
await _localAlbumRepository.upsert(updatedDeviceAlbum, toUpsert: assetsInDevice);
await _mapIosCloudIds(assetsInDevice);
return true;
}
@@ -277,6 +297,7 @@ class LocalSyncService {
}
await _localAlbumRepository.upsert(updatedDeviceAlbum, toUpsert: assetsToUpsert, toDelete: assetsToDelete);
await _mapIosCloudIds(assetsToUpsert);
return true;
} catch (e, s) {
@@ -285,6 +306,29 @@ class LocalSyncService {
return true;
}
Future<void> _mapIosCloudIds(List<LocalAsset> assets) async {
if (!CurrentPlatform.isIOS || assets.isEmpty) {
return;
}
final assetIds = assets.map((a) => a.id).toList();
final cloudMapping = <String, String>{};
final cloudIds = await _nativeSyncApi.getCloudIdForAssetIds(assetIds);
for (int i = 0; i < cloudIds.length; i++) {
final cloudIdResult = cloudIds[i];
if (cloudIdResult.cloudId != null) {
cloudMapping[cloudIdResult.assetId] = cloudIdResult.cloudId!;
} else {
final asset = assets.firstWhereOrNull((a) => a.id == cloudIdResult.assetId);
_log.fine(
"Cannot fetch cloudId for asset with id: ${cloudIdResult.assetId}, name: ${asset?.name}, createdAt: ${asset?.createdAt}. Error: ${cloudIdResult.error ?? "unknown"}",
);
}
}
await _localAlbumRepository.updateCloudMapping(cloudMapping);
}
bool _assetsEqual(LocalAsset a, LocalAsset b) {
if (CurrentPlatform.isAndroid) {
return a.updatedAt.isAtSameMomentAs(b.updatedAt) &&

View File

@@ -1,7 +1,6 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
@@ -42,8 +41,8 @@ class RemoteAlbumService {
AlbumSortMode.title => albums.sortedBy((album) => album.name),
AlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt),
AlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount),
AlbumSortMode.mostRecent => await _sortByAssetDate(albums, aggregation: AssetDateAggregation.end),
AlbumSortMode.mostOldest => await _sortByAssetDate(albums, aggregation: AssetDateAggregation.start),
AlbumSortMode.mostRecent => await _sortByNewestAsset(albums),
AlbumSortMode.mostOldest => await _sortByOldestAsset(albums),
};
return (isReverse ? sorted.reversed : sorted).toList();
@@ -170,25 +169,46 @@ class RemoteAlbumService {
return _repository.getAlbumsContainingAsset(assetId);
}
Future<List<RemoteAlbum>> _sortByAssetDate(
List<RemoteAlbum> albums, {
required AssetDateAggregation aggregation,
}) async {
if (albums.isEmpty) return [];
final albumIds = albums.map((e) => e.id).toList();
final sortedIds = await _repository.getSortedAlbumIds(albumIds, aggregation: aggregation);
final albumMap = Map<String, RemoteAlbum>.fromEntries(albums.map((a) => MapEntry(a.id, a)));
final sortedAlbums = sortedIds.map((id) => albumMap[id]).whereType<RemoteAlbum>().toList();
if (sortedAlbums.length < albums.length) {
final returnedIdSet = sortedIds.toSet();
final emptyAlbums = albums.where((a) => !returnedIdSet.contains(a.id));
sortedAlbums.addAll(emptyAlbums);
Future<List<RemoteAlbum>> _sortByNewestAsset(List<RemoteAlbum> albums) async {
// map album IDs to their newest asset dates
final Map<String, Future<DateTime?>> assetTimestampFutures = {};
for (final album in albums) {
assetTimestampFutures[album.id] = _repository.getNewestAssetTimestamp(album.id);
}
return sortedAlbums;
// await all database queries
final entries = await Future.wait(
assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)),
);
final assetTimestamps = Map.fromEntries(entries);
final sorted = albums.sorted((a, b) {
final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
return aDate.compareTo(bDate);
});
return sorted;
}
Future<List<RemoteAlbum>> _sortByOldestAsset(List<RemoteAlbum> albums) async {
// map album IDs to their oldest asset dates
final Map<String, Future<DateTime?>> assetTimestampFutures = {
for (final album in albums) album.id: _repository.getOldestAssetTimestamp(album.id),
};
// await all database queries
final entries = await Future.wait(
assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)),
);
final assetTimestamps = Map.fromEntries(entries);
final sorted = albums.sorted((a, b) {
final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
return aDate.compareTo(bDate);
});
return sorted.reversed.toList();
}
}

View File

@@ -118,6 +118,10 @@ class SyncStreamService {
return _syncStreamRepository.deleteAssetsV1(data.cast());
case SyncEntityType.assetExifV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast());
case SyncEntityType.assetMetadataV1:
return _syncStreamRepository.updateAssetsMetadataV1(data.cast());
case SyncEntityType.assetMetadataDeleteV1:
return _syncStreamRepository.deleteAssetsMetadataV1(data.cast());
case SyncEntityType.partnerAssetV1:
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'partner');
case SyncEntityType.partnerAssetBackfillV1:

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:immich_mobile/domain/utils/migrate_cloud_ids.dart' as m;
import 'package:immich_mobile/domain/utils/sync_linked_album.dart';
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
import 'package:immich_mobile/utils/isolate.dart';
@@ -22,8 +23,13 @@ class BackgroundSyncManager {
final SyncCallback? onHashingComplete;
final SyncErrorCallback? onHashingError;
final SyncCallback? onCloudIdSyncStart;
final SyncCallback? onCloudIdSyncComplete;
final SyncErrorCallback? onCloudIdSyncError;
Cancelable<bool?>? _syncTask;
Cancelable<void>? _syncWebsocketTask;
Cancelable<void>? _cloudIdSyncTask;
Cancelable<void>? _deviceAlbumSyncTask;
Cancelable<void>? _linkedAlbumSyncTask;
Cancelable<void>? _hashTask;
@@ -38,6 +44,9 @@ class BackgroundSyncManager {
this.onHashingStart,
this.onHashingComplete,
this.onHashingError,
this.onCloudIdSyncStart,
this.onCloudIdSyncComplete,
this.onCloudIdSyncError,
});
Future<void> cancel() async {
@@ -55,6 +64,12 @@ class BackgroundSyncManager {
_syncWebsocketTask?.cancel();
_syncWebsocketTask = null;
if (_cloudIdSyncTask != null) {
futures.add(_cloudIdSyncTask!.future);
}
_cloudIdSyncTask?.cancel();
_cloudIdSyncTask = null;
if (_linkedAlbumSyncTask != null) {
futures.add(_linkedAlbumSyncTask!.future);
}
@@ -121,7 +136,6 @@ class BackgroundSyncManager {
});
}
// No need to cancel the task, as it can also be run when the user logs out
Future<void> hashAssets() {
if (_hashTask != null) {
return _hashTask!.future;
@@ -192,6 +206,25 @@ class BackgroundSyncManager {
_linkedAlbumSyncTask = null;
});
}
Future<void> syncCloudIds() {
if (_cloudIdSyncTask != null) {
return _cloudIdSyncTask!.future;
}
onCloudIdSyncStart?.call();
_cloudIdSyncTask = runInIsolateGentle(computation: m.syncCloudIds);
return _cloudIdSyncTask!
.whenComplete(() {
onCloudIdSyncComplete?.call();
_cloudIdSyncTask = null;
})
.catchError((error) {
onCloudIdSyncError?.call(error.toString());
_cloudIdSyncTask = null;
});
}
}
Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle(

View File

@@ -0,0 +1,175 @@
import 'package:drift/drift.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:logging/logging.dart';
// ignore: import_rule_openapi
import 'package:openapi/api.dart' hide AssetVisibility;
Future<void> syncCloudIds(ProviderContainer ref) async {
if (!CurrentPlatform.isIOS) {
return;
}
final logger = Logger('migrateCloudIds');
final db = ref.read(driftProvider);
// Populate cloud IDs for local assets that don't have one yet
await _populateCloudIds(db);
final serverInfo = await ref.read(serverInfoProvider.notifier).getServerInfo();
final canUpdateMetadata = serverInfo.serverVersion.isAtLeast(major: 2, minor: 4);
if (!canUpdateMetadata) {
logger.fine('Server version does not support asset metadata updates. Skipping cloudId migration.');
return;
}
final canBulkUpdateMetadata = serverInfo.serverVersion.isAtLeast(major: 2, minor: 5);
// Wait for remote sync to complete, so we have up-to-date asset metadata entries
try {
await ref.read(syncStreamServiceProvider).sync();
} catch (e, s) {
logger.fine('Failed to complete remote sync before cloudId migration.', e, s);
return;
}
// Fetch the mapping for backed up assets that have a cloud ID locally but do not have a cloud ID on the server
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
logger.warning('Current user is null. Aborting cloudId migration.');
return;
}
final mappingsToUpdate = await _fetchCloudIdMappings(db, currentUser.id);
// Deduplicate mappings as a single remote asset ID can match multiple local assets
final seenRemoteAssetIds = <String>{};
final uniqueMapping = mappingsToUpdate.where((mapping) {
if (!seenRemoteAssetIds.add(mapping.remoteAssetId)) {
logger.fine('Duplicate remote asset ID found: ${mapping.remoteAssetId}. Skipping duplicate entry.');
return false;
}
return true;
}).toList();
final assetApi = ref.read(apiServiceProvider).assetsApi;
if (canBulkUpdateMetadata) {
await _bulkUpdateCloudIds(assetApi, uniqueMapping);
return;
}
await _sequentialUpdateCloudIds(assetApi, uniqueMapping);
}
Future<void> _sequentialUpdateCloudIds(AssetsApi assetsApi, List<_CloudIdMapping> mappings) async {
for (final mapping in mappings) {
final item = AssetMetadataUpsertItemDto(
key: kMobileMetadataKey,
value: RemoteAssetMobileAppMetadata(
cloudId: mapping.localAsset.cloudId,
createdAt: mapping.localAsset.createdAt.toIso8601String(),
adjustmentTime: mapping.localAsset.adjustmentTime?.toIso8601String(),
latitude: mapping.localAsset.latitude?.toString(),
longitude: mapping.localAsset.longitude?.toString(),
),
);
try {
await assetsApi.updateAssetMetadata(mapping.remoteAssetId, AssetMetadataUpsertDto(items: [item]));
} catch (error, stack) {
Logger('migrateCloudIds').warning('Failed to update metadata for asset ${mapping.remoteAssetId}', error, stack);
}
}
}
Future<void> _bulkUpdateCloudIds(AssetsApi assetsApi, List<_CloudIdMapping> mappings) async {
const batchSize = 10000;
for (int i = 0; i < mappings.length; i += batchSize) {
final endIndex = (i + batchSize > mappings.length) ? mappings.length : i + batchSize;
final batch = mappings.sublist(i, endIndex);
final items = <AssetMetadataBulkUpsertItemDto>[];
for (final mapping in batch) {
items.add(
AssetMetadataBulkUpsertItemDto(
assetId: mapping.remoteAssetId,
key: kMobileMetadataKey,
value: RemoteAssetMobileAppMetadata(
cloudId: mapping.localAsset.cloudId,
createdAt: mapping.localAsset.createdAt.toIso8601String(),
adjustmentTime: mapping.localAsset.adjustmentTime?.toIso8601String(),
latitude: mapping.localAsset.latitude?.toString(),
longitude: mapping.localAsset.longitude?.toString(),
),
),
);
}
try {
await assetsApi.updateBulkAssetMetadata(AssetMetadataBulkUpsertDto(items: items));
} catch (error, stack) {
Logger('migrateCloudIds').warning('Failed to bulk update metadata', error, stack);
}
}
}
Future<void> _populateCloudIds(Drift drift) async {
final query = drift.localAssetEntity.selectOnly()
..addColumns([drift.localAssetEntity.id])
..where(drift.localAssetEntity.iCloudId.isNull());
final ids = await query.map((row) => row.read(drift.localAssetEntity.id)!).get();
final cloudMapping = <String, String>{};
final cloudIds = await NativeSyncApi().getCloudIdForAssetIds(ids);
for (int i = 0; i < cloudIds.length; i++) {
final cloudIdResult = cloudIds[i];
if (cloudIdResult.cloudId != null) {
cloudMapping[cloudIdResult.assetId] = cloudIdResult.cloudId!;
} else {
Logger('migrateCloudIds').fine(
"Cannot fetch cloudId for asset with id: ${cloudIdResult.assetId}. Error: ${cloudIdResult.error ?? "unknown"}",
);
}
}
await DriftLocalAlbumRepository(drift).updateCloudMapping(cloudMapping);
}
typedef _CloudIdMapping = ({String remoteAssetId, LocalAsset localAsset});
Future<List<_CloudIdMapping>> _fetchCloudIdMappings(Drift drift, String userId) async {
final query =
drift.remoteAssetEntity.select().join([
leftOuterJoin(
drift.localAssetEntity,
drift.localAssetEntity.checksum.equalsExp(drift.remoteAssetEntity.checksum),
),
leftOuterJoin(
drift.remoteAssetCloudIdEntity,
drift.remoteAssetEntity.id.equalsExp(drift.remoteAssetCloudIdEntity.assetId),
useColumns: false,
),
])..where(
// Only select assets that have a local cloud ID but either no remote cloud ID or a mismatched eTag
drift.localAssetEntity.id.isNotNull() &
drift.localAssetEntity.iCloudId.isNotNull() &
drift.remoteAssetEntity.ownerId.equals(userId) &
// Skip locked assets as we cannot update them without unlocking first
drift.remoteAssetEntity.visibility.isNotValue(AssetVisibility.locked.index) &
(drift.remoteAssetCloudIdEntity.cloudId.isNull() |
drift.remoteAssetCloudIdEntity.adjustmentTime.isNotExp(drift.localAssetEntity.adjustmentTime) |
drift.remoteAssetCloudIdEntity.latitude.isNotExp(drift.localAssetEntity.latitude) |
drift.remoteAssetCloudIdEntity.longitude.isNotExp(drift.localAssetEntity.longitude) |
drift.remoteAssetCloudIdEntity.createdAt.isNotExp(drift.localAssetEntity.createdAt)),
);
return query.map((row) {
return (
remoteAssetId: row.read(drift.remoteAssetEntity.id)!,
localAsset: row.readTable(drift.localAssetEntity).toDto(),
);
}).get();
}

View File

@@ -5,6 +5,7 @@ import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart';
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)')
class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
const LocalAssetEntity();
@@ -16,6 +17,8 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
IntColumn get orientation => integer().withDefault(const Constant(0))();
TextColumn get iCloudId => text().nullable()();
DateTimeColumn get adjustmentTime => dateTime().nullable()();
RealColumn get latitude => real().nullable()();
@@ -43,5 +46,6 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
cloudId: iCloudId,
);
}

View File

@@ -21,6 +21,7 @@ typedef $$LocalAssetEntityTableCreateCompanionBuilder =
i0.Value<String?> checksum,
i0.Value<bool> isFavorite,
i0.Value<int> orientation,
i0.Value<String?> iCloudId,
i0.Value<DateTime?> adjustmentTime,
i0.Value<double?> latitude,
i0.Value<double?> longitude,
@@ -38,6 +39,7 @@ typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
i0.Value<String?> checksum,
i0.Value<bool> isFavorite,
i0.Value<int> orientation,
i0.Value<String?> iCloudId,
i0.Value<DateTime?> adjustmentTime,
i0.Value<double?> latitude,
i0.Value<double?> longitude,
@@ -108,6 +110,11 @@ class $$LocalAssetEntityTableFilterComposer
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<String> get iCloudId => $composableBuilder(
column: $table.iCloudId,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<DateTime> get adjustmentTime => $composableBuilder(
column: $table.adjustmentTime,
builder: (column) => i0.ColumnFilters(column),
@@ -188,6 +195,11 @@ class $$LocalAssetEntityTableOrderingComposer
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<String> get iCloudId => $composableBuilder(
column: $table.iCloudId,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<DateTime> get adjustmentTime => $composableBuilder(
column: $table.adjustmentTime,
builder: (column) => i0.ColumnOrderings(column),
@@ -252,6 +264,9 @@ class $$LocalAssetEntityTableAnnotationComposer
builder: (column) => column,
);
i0.GeneratedColumn<String> get iCloudId =>
$composableBuilder(column: $table.iCloudId, builder: (column) => column);
i0.GeneratedColumn<DateTime> get adjustmentTime => $composableBuilder(
column: $table.adjustmentTime,
builder: (column) => column,
@@ -315,6 +330,7 @@ class $$LocalAssetEntityTableTableManager
i0.Value<String?> checksum = const i0.Value.absent(),
i0.Value<bool> isFavorite = const i0.Value.absent(),
i0.Value<int> orientation = const i0.Value.absent(),
i0.Value<String?> iCloudId = const i0.Value.absent(),
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
i0.Value<double?> latitude = const i0.Value.absent(),
i0.Value<double?> longitude = const i0.Value.absent(),
@@ -330,6 +346,7 @@ class $$LocalAssetEntityTableTableManager
checksum: checksum,
isFavorite: isFavorite,
orientation: orientation,
iCloudId: iCloudId,
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
@@ -347,6 +364,7 @@ class $$LocalAssetEntityTableTableManager
i0.Value<String?> checksum = const i0.Value.absent(),
i0.Value<bool> isFavorite = const i0.Value.absent(),
i0.Value<int> orientation = const i0.Value.absent(),
i0.Value<String?> iCloudId = const i0.Value.absent(),
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
i0.Value<double?> latitude = const i0.Value.absent(),
i0.Value<double?> longitude = const i0.Value.absent(),
@@ -362,6 +380,7 @@ class $$LocalAssetEntityTableTableManager
checksum: checksum,
isFavorite: isFavorite,
orientation: orientation,
iCloudId: iCloudId,
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
@@ -532,6 +551,17 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
requiredDuringInsert: false,
defaultValue: const i4.Constant(0),
);
static const i0.VerificationMeta _iCloudIdMeta = const i0.VerificationMeta(
'iCloudId',
);
@override
late final i0.GeneratedColumn<String> iCloudId = i0.GeneratedColumn<String>(
'i_cloud_id',
aliasedName,
true,
type: i0.DriftSqlType.string,
requiredDuringInsert: false,
);
static const i0.VerificationMeta _adjustmentTimeMeta =
const i0.VerificationMeta('adjustmentTime');
@override
@@ -578,6 +608,7 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
checksum,
isFavorite,
orientation,
iCloudId,
adjustmentTime,
latitude,
longitude,
@@ -661,6 +692,12 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
),
);
}
if (data.containsKey('i_cloud_id')) {
context.handle(
_iCloudIdMeta,
iCloudId.isAcceptableOrUnknown(data['i_cloud_id']!, _iCloudIdMeta),
);
}
if (data.containsKey('adjustment_time')) {
context.handle(
_adjustmentTimeMeta,
@@ -740,6 +777,10 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
i0.DriftSqlType.int,
data['${effectivePrefix}orientation'],
)!,
iCloudId: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}i_cloud_id'],
),
adjustmentTime: attachedDatabase.typeMapping.read(
i0.DriftSqlType.dateTime,
data['${effectivePrefix}adjustment_time'],
@@ -781,6 +822,7 @@ class LocalAssetEntityData extends i0.DataClass
final String? checksum;
final bool isFavorite;
final int orientation;
final String? iCloudId;
final DateTime? adjustmentTime;
final double? latitude;
final double? longitude;
@@ -796,6 +838,7 @@ class LocalAssetEntityData extends i0.DataClass
this.checksum,
required this.isFavorite,
required this.orientation,
this.iCloudId,
this.adjustmentTime,
this.latitude,
this.longitude,
@@ -826,6 +869,9 @@ class LocalAssetEntityData extends i0.DataClass
}
map['is_favorite'] = i0.Variable<bool>(isFavorite);
map['orientation'] = i0.Variable<int>(orientation);
if (!nullToAbsent || iCloudId != null) {
map['i_cloud_id'] = i0.Variable<String>(iCloudId);
}
if (!nullToAbsent || adjustmentTime != null) {
map['adjustment_time'] = i0.Variable<DateTime>(adjustmentTime);
}
@@ -857,6 +903,7 @@ class LocalAssetEntityData extends i0.DataClass
checksum: serializer.fromJson<String?>(json['checksum']),
isFavorite: serializer.fromJson<bool>(json['isFavorite']),
orientation: serializer.fromJson<int>(json['orientation']),
iCloudId: serializer.fromJson<String?>(json['iCloudId']),
adjustmentTime: serializer.fromJson<DateTime?>(json['adjustmentTime']),
latitude: serializer.fromJson<double?>(json['latitude']),
longitude: serializer.fromJson<double?>(json['longitude']),
@@ -879,6 +926,7 @@ class LocalAssetEntityData extends i0.DataClass
'checksum': serializer.toJson<String?>(checksum),
'isFavorite': serializer.toJson<bool>(isFavorite),
'orientation': serializer.toJson<int>(orientation),
'iCloudId': serializer.toJson<String?>(iCloudId),
'adjustmentTime': serializer.toJson<DateTime?>(adjustmentTime),
'latitude': serializer.toJson<double?>(latitude),
'longitude': serializer.toJson<double?>(longitude),
@@ -897,6 +945,7 @@ class LocalAssetEntityData extends i0.DataClass
i0.Value<String?> checksum = const i0.Value.absent(),
bool? isFavorite,
int? orientation,
i0.Value<String?> iCloudId = const i0.Value.absent(),
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
i0.Value<double?> latitude = const i0.Value.absent(),
i0.Value<double?> longitude = const i0.Value.absent(),
@@ -914,6 +963,7 @@ class LocalAssetEntityData extends i0.DataClass
checksum: checksum.present ? checksum.value : this.checksum,
isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation,
iCloudId: iCloudId.present ? iCloudId.value : this.iCloudId,
adjustmentTime: adjustmentTime.present
? adjustmentTime.value
: this.adjustmentTime,
@@ -939,6 +989,7 @@ class LocalAssetEntityData extends i0.DataClass
orientation: data.orientation.present
? data.orientation.value
: this.orientation,
iCloudId: data.iCloudId.present ? data.iCloudId.value : this.iCloudId,
adjustmentTime: data.adjustmentTime.present
? data.adjustmentTime.value
: this.adjustmentTime,
@@ -961,6 +1012,7 @@ class LocalAssetEntityData extends i0.DataClass
..write('checksum: $checksum, ')
..write('isFavorite: $isFavorite, ')
..write('orientation: $orientation, ')
..write('iCloudId: $iCloudId, ')
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude')
@@ -981,6 +1033,7 @@ class LocalAssetEntityData extends i0.DataClass
checksum,
isFavorite,
orientation,
iCloudId,
adjustmentTime,
latitude,
longitude,
@@ -1000,6 +1053,7 @@ class LocalAssetEntityData extends i0.DataClass
other.checksum == this.checksum &&
other.isFavorite == this.isFavorite &&
other.orientation == this.orientation &&
other.iCloudId == this.iCloudId &&
other.adjustmentTime == this.adjustmentTime &&
other.latitude == this.latitude &&
other.longitude == this.longitude);
@@ -1018,6 +1072,7 @@ class LocalAssetEntityCompanion
final i0.Value<String?> checksum;
final i0.Value<bool> isFavorite;
final i0.Value<int> orientation;
final i0.Value<String?> iCloudId;
final i0.Value<DateTime?> adjustmentTime;
final i0.Value<double?> latitude;
final i0.Value<double?> longitude;
@@ -1033,6 +1088,7 @@ class LocalAssetEntityCompanion
this.checksum = const i0.Value.absent(),
this.isFavorite = const i0.Value.absent(),
this.orientation = const i0.Value.absent(),
this.iCloudId = const i0.Value.absent(),
this.adjustmentTime = const i0.Value.absent(),
this.latitude = const i0.Value.absent(),
this.longitude = const i0.Value.absent(),
@@ -1049,6 +1105,7 @@ class LocalAssetEntityCompanion
this.checksum = const i0.Value.absent(),
this.isFavorite = const i0.Value.absent(),
this.orientation = const i0.Value.absent(),
this.iCloudId = const i0.Value.absent(),
this.adjustmentTime = const i0.Value.absent(),
this.latitude = const i0.Value.absent(),
this.longitude = const i0.Value.absent(),
@@ -1067,6 +1124,7 @@ class LocalAssetEntityCompanion
i0.Expression<String>? checksum,
i0.Expression<bool>? isFavorite,
i0.Expression<int>? orientation,
i0.Expression<String>? iCloudId,
i0.Expression<DateTime>? adjustmentTime,
i0.Expression<double>? latitude,
i0.Expression<double>? longitude,
@@ -1083,6 +1141,7 @@ class LocalAssetEntityCompanion
if (checksum != null) 'checksum': checksum,
if (isFavorite != null) 'is_favorite': isFavorite,
if (orientation != null) 'orientation': orientation,
if (iCloudId != null) 'i_cloud_id': iCloudId,
if (adjustmentTime != null) 'adjustment_time': adjustmentTime,
if (latitude != null) 'latitude': latitude,
if (longitude != null) 'longitude': longitude,
@@ -1101,6 +1160,7 @@ class LocalAssetEntityCompanion
i0.Value<String?>? checksum,
i0.Value<bool>? isFavorite,
i0.Value<int>? orientation,
i0.Value<String?>? iCloudId,
i0.Value<DateTime?>? adjustmentTime,
i0.Value<double?>? latitude,
i0.Value<double?>? longitude,
@@ -1117,6 +1177,7 @@ class LocalAssetEntityCompanion
checksum: checksum ?? this.checksum,
isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation,
iCloudId: iCloudId ?? this.iCloudId,
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
@@ -1161,6 +1222,9 @@ class LocalAssetEntityCompanion
if (orientation.present) {
map['orientation'] = i0.Variable<int>(orientation.value);
}
if (iCloudId.present) {
map['i_cloud_id'] = i0.Variable<String>(iCloudId.value);
}
if (adjustmentTime.present) {
map['adjustment_time'] = i0.Variable<DateTime>(adjustmentTime.value);
}
@@ -1187,6 +1251,7 @@ class LocalAssetEntityCompanion
..write('checksum: $checksum, ')
..write('isFavorite: $isFavorite, ')
..write('orientation: $orientation, ')
..write('iCloudId: $iCloudId, ')
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude')
@@ -1194,3 +1259,8 @@ class LocalAssetEntityCompanion
.toString();
}
}
i0.Index get idxLocalAssetCloudId => i0.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);

View File

@@ -21,7 +21,11 @@ SELECT
rae.owner_id,
rae.live_photo_video_id,
0 as orientation,
rae.stack_id
rae.stack_id,
NULL as i_cloud_id,
NULL as latitude,
NULL as longitude,
NULL as adjustmentTime
FROM
remote_asset_entity rae
LEFT JOIN
@@ -53,7 +57,11 @@ SELECT
NULL as owner_id,
NULL as live_photo_video_id,
lae.orientation,
NULL as stack_id
NULL as stack_id,
lae.i_cloud_id,
lae.latitude,
lae.longitude,
lae.adjustment_time
FROM
local_asset_entity lae
WHERE NOT EXISTS (

View File

@@ -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_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id 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_in_seconds, 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 FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}',
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, 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 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_in_seconds, 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 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}',
variables: [
for (var $ in userIds) i0.Variable<String>($),
...generatedlimit.introducedVariables,
@@ -62,6 +62,10 @@ class MergedAssetDrift extends i1.ModularAccessor {
livePhotoVideoId: row.readNullable<String>('live_photo_video_id'),
orientation: row.read<int>('orientation'),
stackId: row.readNullable<String>('stack_id'),
iCloudId: row.readNullable<String>('i_cloud_id'),
latitude: row.readNullable<double>('latitude'),
longitude: row.readNullable<double>('longitude'),
adjustmentTime: row.readNullable<DateTime>('adjustmentTime'),
),
);
}
@@ -129,6 +133,10 @@ class MergedAssetResult {
final String? livePhotoVideoId;
final int orientation;
final String? stackId;
final String? iCloudId;
final double? latitude;
final double? longitude;
final DateTime? adjustmentTime;
MergedAssetResult({
this.remoteId,
this.localId,
@@ -146,6 +154,10 @@ class MergedAssetResult {
this.livePhotoVideoId,
required this.orientation,
this.stackId,
this.iCloudId,
this.latitude,
this.longitude,
this.adjustmentTime,
});
}

View File

@@ -3,7 +3,6 @@ import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_id ON remote_album_asset_entity (album_id)')
class RemoteAlbumAssetEntity extends Table with DriftDefaultsMixin {
const RemoteAlbumAssetEntity();

View File

@@ -441,10 +441,6 @@ typedef $$RemoteAlbumAssetEntityTableProcessedTableManager =
i1.RemoteAlbumAssetEntityData,
i0.PrefetchHooks Function({bool assetId, bool albumId})
>;
i0.Index get idxRemoteAlbumAssetAlbumId => i0.Index(
'idx_remote_album_asset_album_id',
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_id ON remote_album_asset_entity (album_id)',
);
class $RemoteAlbumAssetEntityTable extends i2.RemoteAlbumAssetEntity
with

View File

@@ -0,0 +1,20 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class RemoteAssetCloudIdEntity extends Table with DriftDefaultsMixin {
TextColumn get assetId => text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)();
TextColumn get cloudId => text().nullable()();
DateTimeColumn get createdAt => dateTime().nullable()();
DateTimeColumn get adjustmentTime => dateTime().nullable()();
RealColumn get latitude => real().nullable()();
RealColumn get longitude => real().nullable()();
@override
Set<Column> get primaryKey => {assetId};
}

View File

@@ -0,0 +1,826 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart'
as i1;
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.dart'
as i2;
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'
as i3;
import 'package:drift/internal/modular.dart' as i4;
typedef $$RemoteAssetCloudIdEntityTableCreateCompanionBuilder =
i1.RemoteAssetCloudIdEntityCompanion Function({
required String assetId,
i0.Value<String?> cloudId,
i0.Value<DateTime?> createdAt,
i0.Value<DateTime?> adjustmentTime,
i0.Value<double?> latitude,
i0.Value<double?> longitude,
});
typedef $$RemoteAssetCloudIdEntityTableUpdateCompanionBuilder =
i1.RemoteAssetCloudIdEntityCompanion Function({
i0.Value<String> assetId,
i0.Value<String?> cloudId,
i0.Value<DateTime?> createdAt,
i0.Value<DateTime?> adjustmentTime,
i0.Value<double?> latitude,
i0.Value<double?> longitude,
});
final class $$RemoteAssetCloudIdEntityTableReferences
extends
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$RemoteAssetCloudIdEntityTable,
i1.RemoteAssetCloudIdEntityData
> {
$$RemoteAssetCloudIdEntityTableReferences(
super.$_db,
super.$_table,
super.$_typedResult,
);
static i3.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) =>
i4.ReadDatabaseContainer(db)
.resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity')
.createAlias(
i0.$_aliasNameGenerator(
i4.ReadDatabaseContainer(db)
.resultSet<i1.$RemoteAssetCloudIdEntityTable>(
'remote_asset_cloud_id_entity',
)
.assetId,
i4.ReadDatabaseContainer(
db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity').id,
),
);
i3.$$RemoteAssetEntityTableProcessedTableManager get assetId {
final $_column = $_itemColumn<String>('asset_id')!;
final manager = i3
.$$RemoteAssetEntityTableTableManager(
$_db,
i4.ReadDatabaseContainer(
$_db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
)
.filter((f) => f.id.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_assetIdTable($_db));
if (item == null) return manager;
return i0.ProcessedTableManager(
manager.$state.copyWith(prefetchedData: [item]),
);
}
}
class $$RemoteAssetCloudIdEntityTableFilterComposer
extends
i0.Composer<i0.GeneratedDatabase, i1.$RemoteAssetCloudIdEntityTable> {
$$RemoteAssetCloudIdEntityTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnFilters<String> get cloudId => $composableBuilder(
column: $table.cloudId,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<DateTime> get adjustmentTime => $composableBuilder(
column: $table.adjustmentTime,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<double> get latitude => $composableBuilder(
column: $table.latitude,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<double> get longitude => $composableBuilder(
column: $table.longitude,
builder: (column) => i0.ColumnFilters(column),
);
i3.$$RemoteAssetEntityTableFilterComposer get assetId {
final i3.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.assetId,
referencedTable: i4.ReadDatabaseContainer(
$db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i3.$$RemoteAssetEntityTableFilterComposer(
$db: $db,
$table: i4.ReadDatabaseContainer(
$db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
}
class $$RemoteAssetCloudIdEntityTableOrderingComposer
extends
i0.Composer<i0.GeneratedDatabase, i1.$RemoteAssetCloudIdEntityTable> {
$$RemoteAssetCloudIdEntityTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnOrderings<String> get cloudId => $composableBuilder(
column: $table.cloudId,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<DateTime> get adjustmentTime => $composableBuilder(
column: $table.adjustmentTime,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<double> get latitude => $composableBuilder(
column: $table.latitude,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<double> get longitude => $composableBuilder(
column: $table.longitude,
builder: (column) => i0.ColumnOrderings(column),
);
i3.$$RemoteAssetEntityTableOrderingComposer get assetId {
final i3.$$RemoteAssetEntityTableOrderingComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.assetId,
referencedTable: i4.ReadDatabaseContainer(
$db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i3.$$RemoteAssetEntityTableOrderingComposer(
$db: $db,
$table: i4.ReadDatabaseContainer(
$db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
}
class $$RemoteAssetCloudIdEntityTableAnnotationComposer
extends
i0.Composer<i0.GeneratedDatabase, i1.$RemoteAssetCloudIdEntityTable> {
$$RemoteAssetCloudIdEntityTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.GeneratedColumn<String> get cloudId =>
$composableBuilder(column: $table.cloudId, builder: (column) => column);
i0.GeneratedColumn<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
i0.GeneratedColumn<DateTime> get adjustmentTime => $composableBuilder(
column: $table.adjustmentTime,
builder: (column) => column,
);
i0.GeneratedColumn<double> get latitude =>
$composableBuilder(column: $table.latitude, builder: (column) => column);
i0.GeneratedColumn<double> get longitude =>
$composableBuilder(column: $table.longitude, builder: (column) => column);
i3.$$RemoteAssetEntityTableAnnotationComposer get assetId {
final i3.$$RemoteAssetEntityTableAnnotationComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.assetId,
referencedTable: i4.ReadDatabaseContainer(
$db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i3.$$RemoteAssetEntityTableAnnotationComposer(
$db: $db,
$table: i4.ReadDatabaseContainer(
$db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
}
class $$RemoteAssetCloudIdEntityTableTableManager
extends
i0.RootTableManager<
i0.GeneratedDatabase,
i1.$RemoteAssetCloudIdEntityTable,
i1.RemoteAssetCloudIdEntityData,
i1.$$RemoteAssetCloudIdEntityTableFilterComposer,
i1.$$RemoteAssetCloudIdEntityTableOrderingComposer,
i1.$$RemoteAssetCloudIdEntityTableAnnotationComposer,
$$RemoteAssetCloudIdEntityTableCreateCompanionBuilder,
$$RemoteAssetCloudIdEntityTableUpdateCompanionBuilder,
(
i1.RemoteAssetCloudIdEntityData,
i1.$$RemoteAssetCloudIdEntityTableReferences,
),
i1.RemoteAssetCloudIdEntityData,
i0.PrefetchHooks Function({bool assetId})
> {
$$RemoteAssetCloudIdEntityTableTableManager(
i0.GeneratedDatabase db,
i1.$RemoteAssetCloudIdEntityTable table,
) : super(
i0.TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
i1.$$RemoteAssetCloudIdEntityTableFilterComposer(
$db: db,
$table: table,
),
createOrderingComposer: () =>
i1.$$RemoteAssetCloudIdEntityTableOrderingComposer(
$db: db,
$table: table,
),
createComputedFieldComposer: () =>
i1.$$RemoteAssetCloudIdEntityTableAnnotationComposer(
$db: db,
$table: table,
),
updateCompanionCallback:
({
i0.Value<String> assetId = const i0.Value.absent(),
i0.Value<String?> cloudId = const i0.Value.absent(),
i0.Value<DateTime?> createdAt = const i0.Value.absent(),
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
i0.Value<double?> latitude = const i0.Value.absent(),
i0.Value<double?> longitude = const i0.Value.absent(),
}) => i1.RemoteAssetCloudIdEntityCompanion(
assetId: assetId,
cloudId: cloudId,
createdAt: createdAt,
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
),
createCompanionCallback:
({
required String assetId,
i0.Value<String?> cloudId = const i0.Value.absent(),
i0.Value<DateTime?> createdAt = const i0.Value.absent(),
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
i0.Value<double?> latitude = const i0.Value.absent(),
i0.Value<double?> longitude = const i0.Value.absent(),
}) => i1.RemoteAssetCloudIdEntityCompanion.insert(
assetId: assetId,
cloudId: cloudId,
createdAt: createdAt,
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
),
withReferenceMapper: (p0) => p0
.map(
(e) => (
e.readTable(table),
i1.$$RemoteAssetCloudIdEntityTableReferences(db, table, e),
),
)
.toList(),
prefetchHooksCallback: ({assetId = false}) {
return i0.PrefetchHooks(
db: db,
explicitlyWatchedTables: [],
addJoins:
<
T extends i0.TableManagerState<
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic
>
>(state) {
if (assetId) {
state =
state.withJoin(
currentTable: table,
currentColumn: table.assetId,
referencedTable: i1
.$$RemoteAssetCloudIdEntityTableReferences
._assetIdTable(db),
referencedColumn: i1
.$$RemoteAssetCloudIdEntityTableReferences
._assetIdTable(db)
.id,
)
as T;
}
return state;
},
getPrefetchedDataCallback: (items) async {
return [];
},
);
},
),
);
}
typedef $$RemoteAssetCloudIdEntityTableProcessedTableManager =
i0.ProcessedTableManager<
i0.GeneratedDatabase,
i1.$RemoteAssetCloudIdEntityTable,
i1.RemoteAssetCloudIdEntityData,
i1.$$RemoteAssetCloudIdEntityTableFilterComposer,
i1.$$RemoteAssetCloudIdEntityTableOrderingComposer,
i1.$$RemoteAssetCloudIdEntityTableAnnotationComposer,
$$RemoteAssetCloudIdEntityTableCreateCompanionBuilder,
$$RemoteAssetCloudIdEntityTableUpdateCompanionBuilder,
(
i1.RemoteAssetCloudIdEntityData,
i1.$$RemoteAssetCloudIdEntityTableReferences,
),
i1.RemoteAssetCloudIdEntityData,
i0.PrefetchHooks Function({bool assetId})
>;
class $RemoteAssetCloudIdEntityTable extends i2.RemoteAssetCloudIdEntity
with
i0.TableInfo<
$RemoteAssetCloudIdEntityTable,
i1.RemoteAssetCloudIdEntityData
> {
@override
final i0.GeneratedDatabase attachedDatabase;
final String? _alias;
$RemoteAssetCloudIdEntityTable(this.attachedDatabase, [this._alias]);
static const i0.VerificationMeta _assetIdMeta = const i0.VerificationMeta(
'assetId',
);
@override
late final i0.GeneratedColumn<String> assetId = i0.GeneratedColumn<String>(
'asset_id',
aliasedName,
false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'REFERENCES remote_asset_entity (id) ON DELETE CASCADE',
),
);
static const i0.VerificationMeta _cloudIdMeta = const i0.VerificationMeta(
'cloudId',
);
@override
late final i0.GeneratedColumn<String> cloudId = i0.GeneratedColumn<String>(
'cloud_id',
aliasedName,
true,
type: i0.DriftSqlType.string,
requiredDuringInsert: false,
);
static const i0.VerificationMeta _createdAtMeta = const i0.VerificationMeta(
'createdAt',
);
@override
late final i0.GeneratedColumn<DateTime> createdAt =
i0.GeneratedColumn<DateTime>(
'created_at',
aliasedName,
true,
type: i0.DriftSqlType.dateTime,
requiredDuringInsert: false,
);
static const i0.VerificationMeta _adjustmentTimeMeta =
const i0.VerificationMeta('adjustmentTime');
@override
late final i0.GeneratedColumn<DateTime> adjustmentTime =
i0.GeneratedColumn<DateTime>(
'adjustment_time',
aliasedName,
true,
type: i0.DriftSqlType.dateTime,
requiredDuringInsert: false,
);
static const i0.VerificationMeta _latitudeMeta = const i0.VerificationMeta(
'latitude',
);
@override
late final i0.GeneratedColumn<double> latitude = i0.GeneratedColumn<double>(
'latitude',
aliasedName,
true,
type: i0.DriftSqlType.double,
requiredDuringInsert: false,
);
static const i0.VerificationMeta _longitudeMeta = const i0.VerificationMeta(
'longitude',
);
@override
late final i0.GeneratedColumn<double> longitude = i0.GeneratedColumn<double>(
'longitude',
aliasedName,
true,
type: i0.DriftSqlType.double,
requiredDuringInsert: false,
);
@override
List<i0.GeneratedColumn> get $columns => [
assetId,
cloudId,
createdAt,
adjustmentTime,
latitude,
longitude,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'remote_asset_cloud_id_entity';
@override
i0.VerificationContext validateIntegrity(
i0.Insertable<i1.RemoteAssetCloudIdEntityData> instance, {
bool isInserting = false,
}) {
final context = i0.VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('asset_id')) {
context.handle(
_assetIdMeta,
assetId.isAcceptableOrUnknown(data['asset_id']!, _assetIdMeta),
);
} else if (isInserting) {
context.missing(_assetIdMeta);
}
if (data.containsKey('cloud_id')) {
context.handle(
_cloudIdMeta,
cloudId.isAcceptableOrUnknown(data['cloud_id']!, _cloudIdMeta),
);
}
if (data.containsKey('created_at')) {
context.handle(
_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta),
);
}
if (data.containsKey('adjustment_time')) {
context.handle(
_adjustmentTimeMeta,
adjustmentTime.isAcceptableOrUnknown(
data['adjustment_time']!,
_adjustmentTimeMeta,
),
);
}
if (data.containsKey('latitude')) {
context.handle(
_latitudeMeta,
latitude.isAcceptableOrUnknown(data['latitude']!, _latitudeMeta),
);
}
if (data.containsKey('longitude')) {
context.handle(
_longitudeMeta,
longitude.isAcceptableOrUnknown(data['longitude']!, _longitudeMeta),
);
}
return context;
}
@override
Set<i0.GeneratedColumn> get $primaryKey => {assetId};
@override
i1.RemoteAssetCloudIdEntityData map(
Map<String, dynamic> data, {
String? tablePrefix,
}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return i1.RemoteAssetCloudIdEntityData(
assetId: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}asset_id'],
)!,
cloudId: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}cloud_id'],
),
createdAt: attachedDatabase.typeMapping.read(
i0.DriftSqlType.dateTime,
data['${effectivePrefix}created_at'],
),
adjustmentTime: attachedDatabase.typeMapping.read(
i0.DriftSqlType.dateTime,
data['${effectivePrefix}adjustment_time'],
),
latitude: attachedDatabase.typeMapping.read(
i0.DriftSqlType.double,
data['${effectivePrefix}latitude'],
),
longitude: attachedDatabase.typeMapping.read(
i0.DriftSqlType.double,
data['${effectivePrefix}longitude'],
),
);
}
@override
$RemoteAssetCloudIdEntityTable createAlias(String alias) {
return $RemoteAssetCloudIdEntityTable(attachedDatabase, alias);
}
@override
bool get withoutRowId => true;
@override
bool get isStrict => true;
}
class RemoteAssetCloudIdEntityData extends i0.DataClass
implements i0.Insertable<i1.RemoteAssetCloudIdEntityData> {
final String assetId;
final String? cloudId;
final DateTime? createdAt;
final DateTime? adjustmentTime;
final double? latitude;
final double? longitude;
const RemoteAssetCloudIdEntityData({
required this.assetId,
this.cloudId,
this.createdAt,
this.adjustmentTime,
this.latitude,
this.longitude,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
map['asset_id'] = i0.Variable<String>(assetId);
if (!nullToAbsent || cloudId != null) {
map['cloud_id'] = i0.Variable<String>(cloudId);
}
if (!nullToAbsent || createdAt != null) {
map['created_at'] = i0.Variable<DateTime>(createdAt);
}
if (!nullToAbsent || adjustmentTime != null) {
map['adjustment_time'] = i0.Variable<DateTime>(adjustmentTime);
}
if (!nullToAbsent || latitude != null) {
map['latitude'] = i0.Variable<double>(latitude);
}
if (!nullToAbsent || longitude != null) {
map['longitude'] = i0.Variable<double>(longitude);
}
return map;
}
factory RemoteAssetCloudIdEntityData.fromJson(
Map<String, dynamic> json, {
i0.ValueSerializer? serializer,
}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return RemoteAssetCloudIdEntityData(
assetId: serializer.fromJson<String>(json['assetId']),
cloudId: serializer.fromJson<String?>(json['cloudId']),
createdAt: serializer.fromJson<DateTime?>(json['createdAt']),
adjustmentTime: serializer.fromJson<DateTime?>(json['adjustmentTime']),
latitude: serializer.fromJson<double?>(json['latitude']),
longitude: serializer.fromJson<double?>(json['longitude']),
);
}
@override
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'assetId': serializer.toJson<String>(assetId),
'cloudId': serializer.toJson<String?>(cloudId),
'createdAt': serializer.toJson<DateTime?>(createdAt),
'adjustmentTime': serializer.toJson<DateTime?>(adjustmentTime),
'latitude': serializer.toJson<double?>(latitude),
'longitude': serializer.toJson<double?>(longitude),
};
}
i1.RemoteAssetCloudIdEntityData copyWith({
String? assetId,
i0.Value<String?> cloudId = const i0.Value.absent(),
i0.Value<DateTime?> createdAt = const i0.Value.absent(),
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
i0.Value<double?> latitude = const i0.Value.absent(),
i0.Value<double?> longitude = const i0.Value.absent(),
}) => i1.RemoteAssetCloudIdEntityData(
assetId: assetId ?? this.assetId,
cloudId: cloudId.present ? cloudId.value : this.cloudId,
createdAt: createdAt.present ? createdAt.value : this.createdAt,
adjustmentTime: adjustmentTime.present
? adjustmentTime.value
: this.adjustmentTime,
latitude: latitude.present ? latitude.value : this.latitude,
longitude: longitude.present ? longitude.value : this.longitude,
);
RemoteAssetCloudIdEntityData copyWithCompanion(
i1.RemoteAssetCloudIdEntityCompanion data,
) {
return RemoteAssetCloudIdEntityData(
assetId: data.assetId.present ? data.assetId.value : this.assetId,
cloudId: data.cloudId.present ? data.cloudId.value : this.cloudId,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
adjustmentTime: data.adjustmentTime.present
? data.adjustmentTime.value
: this.adjustmentTime,
latitude: data.latitude.present ? data.latitude.value : this.latitude,
longitude: data.longitude.present ? data.longitude.value : this.longitude,
);
}
@override
String toString() {
return (StringBuffer('RemoteAssetCloudIdEntityData(')
..write('assetId: $assetId, ')
..write('cloudId: $cloudId, ')
..write('createdAt: $createdAt, ')
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(
assetId,
cloudId,
createdAt,
adjustmentTime,
latitude,
longitude,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is i1.RemoteAssetCloudIdEntityData &&
other.assetId == this.assetId &&
other.cloudId == this.cloudId &&
other.createdAt == this.createdAt &&
other.adjustmentTime == this.adjustmentTime &&
other.latitude == this.latitude &&
other.longitude == this.longitude);
}
class RemoteAssetCloudIdEntityCompanion
extends i0.UpdateCompanion<i1.RemoteAssetCloudIdEntityData> {
final i0.Value<String> assetId;
final i0.Value<String?> cloudId;
final i0.Value<DateTime?> createdAt;
final i0.Value<DateTime?> adjustmentTime;
final i0.Value<double?> latitude;
final i0.Value<double?> longitude;
const RemoteAssetCloudIdEntityCompanion({
this.assetId = const i0.Value.absent(),
this.cloudId = const i0.Value.absent(),
this.createdAt = const i0.Value.absent(),
this.adjustmentTime = const i0.Value.absent(),
this.latitude = const i0.Value.absent(),
this.longitude = const i0.Value.absent(),
});
RemoteAssetCloudIdEntityCompanion.insert({
required String assetId,
this.cloudId = const i0.Value.absent(),
this.createdAt = const i0.Value.absent(),
this.adjustmentTime = const i0.Value.absent(),
this.latitude = const i0.Value.absent(),
this.longitude = const i0.Value.absent(),
}) : assetId = i0.Value(assetId);
static i0.Insertable<i1.RemoteAssetCloudIdEntityData> custom({
i0.Expression<String>? assetId,
i0.Expression<String>? cloudId,
i0.Expression<DateTime>? createdAt,
i0.Expression<DateTime>? adjustmentTime,
i0.Expression<double>? latitude,
i0.Expression<double>? longitude,
}) {
return i0.RawValuesInsertable({
if (assetId != null) 'asset_id': assetId,
if (cloudId != null) 'cloud_id': cloudId,
if (createdAt != null) 'created_at': createdAt,
if (adjustmentTime != null) 'adjustment_time': adjustmentTime,
if (latitude != null) 'latitude': latitude,
if (longitude != null) 'longitude': longitude,
});
}
i1.RemoteAssetCloudIdEntityCompanion copyWith({
i0.Value<String>? assetId,
i0.Value<String?>? cloudId,
i0.Value<DateTime?>? createdAt,
i0.Value<DateTime?>? adjustmentTime,
i0.Value<double?>? latitude,
i0.Value<double?>? longitude,
}) {
return i1.RemoteAssetCloudIdEntityCompanion(
assetId: assetId ?? this.assetId,
cloudId: cloudId ?? this.cloudId,
createdAt: createdAt ?? this.createdAt,
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
);
}
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
if (assetId.present) {
map['asset_id'] = i0.Variable<String>(assetId.value);
}
if (cloudId.present) {
map['cloud_id'] = i0.Variable<String>(cloudId.value);
}
if (createdAt.present) {
map['created_at'] = i0.Variable<DateTime>(createdAt.value);
}
if (adjustmentTime.present) {
map['adjustment_time'] = i0.Variable<DateTime>(adjustmentTime.value);
}
if (latitude.present) {
map['latitude'] = i0.Variable<double>(latitude.value);
}
if (longitude.present) {
map['longitude'] = i0.Variable<double>(longitude.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('RemoteAssetCloudIdEntityCompanion(')
..write('assetId: $assetId, ')
..write('cloudId: $cloudId, ')
..write('createdAt: $createdAt, ')
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude')
..write(')'))
.toString();
}
}

View File

@@ -18,6 +18,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
@@ -57,6 +58,7 @@ class IsarDatabaseRepository implements IDatabaseRepository {
RemoteAlbumEntity,
RemoteAlbumAssetEntity,
RemoteAlbumUserEntity,
RemoteAssetCloudIdEntity,
MemoryEntity,
MemoryAssetEntity,
StackEntity,
@@ -194,7 +196,10 @@ class Drift extends $Drift implements IDatabaseRepository {
await m.addColumn(v15.trashedLocalAssetEntity, v15.trashedLocalAssetEntity.source);
},
from15To16: (m, v16) async {
await m.createIndex(v16.idxRemoteAlbumAssetAlbumId);
// Add i_cloud_id to local and remote asset tables
await m.addColumn(v16.localAssetEntity, v16.localAssetEntity.iCloudId);
await m.createIndex(v16.idxLocalAssetCloudId);
await m.createTable(v16.remoteAssetCloudIdEntity);
},
),
);

View File

@@ -27,21 +27,23 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.
as i12;
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart'
as i13;
import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart'
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart'
as i14;
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart'
import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart'
as i15;
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart'
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart'
as i16;
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart'
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart'
as i17;
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart'
as i18;
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'
as i19;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'
as i20;
import 'package:drift/internal/modular.dart' as i21;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
as i21;
import 'package:drift/internal/modular.dart' as i22;
abstract class $Drift extends i0.GeneratedDatabase {
$Drift(i0.QueryExecutor e) : super(e);
@@ -72,18 +74,20 @@ abstract class $Drift extends i0.GeneratedDatabase {
.$RemoteAlbumAssetEntityTable(this);
late final i13.$RemoteAlbumUserEntityTable remoteAlbumUserEntity = i13
.$RemoteAlbumUserEntityTable(this);
late final i14.$MemoryEntityTable memoryEntity = i14.$MemoryEntityTable(this);
late final i15.$MemoryAssetEntityTable memoryAssetEntity = i15
late final i14.$RemoteAssetCloudIdEntityTable remoteAssetCloudIdEntity = i14
.$RemoteAssetCloudIdEntityTable(this);
late final i15.$MemoryEntityTable memoryEntity = i15.$MemoryEntityTable(this);
late final i16.$MemoryAssetEntityTable memoryAssetEntity = i16
.$MemoryAssetEntityTable(this);
late final i16.$PersonEntityTable personEntity = i16.$PersonEntityTable(this);
late final i17.$AssetFaceEntityTable assetFaceEntity = i17
late final i17.$PersonEntityTable personEntity = i17.$PersonEntityTable(this);
late final i18.$AssetFaceEntityTable assetFaceEntity = i18
.$AssetFaceEntityTable(this);
late final i18.$StoreEntityTable storeEntity = i18.$StoreEntityTable(this);
late final i19.$TrashedLocalAssetEntityTable trashedLocalAssetEntity = i19
late final i19.$StoreEntityTable storeEntity = i19.$StoreEntityTable(this);
late final i20.$TrashedLocalAssetEntityTable trashedLocalAssetEntity = i20
.$TrashedLocalAssetEntityTable(this);
i20.MergedAssetDrift get mergedAssetDrift => i21.ReadDatabaseContainer(
i21.MergedAssetDrift get mergedAssetDrift => i22.ReadDatabaseContainer(
this,
).accessor<i20.MergedAssetDrift>(i20.MergedAssetDrift.new);
).accessor<i21.MergedAssetDrift>(i21.MergedAssetDrift.new);
@override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@@ -97,6 +101,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
localAlbumEntity,
localAlbumAssetEntity,
i4.idxLocalAssetChecksum,
i4.idxLocalAssetCloudId,
i2.idxRemoteAssetOwnerChecksum,
i2.uQRemoteAssetsOwnerChecksum,
i2.uQRemoteAssetsOwnerLibraryChecksum,
@@ -107,6 +112,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
@@ -114,9 +120,8 @@ abstract class $Drift extends i0.GeneratedDatabase {
storeEntity,
trashedLocalAssetEntity,
i11.idxLatLng,
i12.idxRemoteAlbumAssetAlbumId,
i19.idxTrashedLocalAssetChecksum,
i19.idxTrashedLocalAssetAlbum,
i20.idxTrashedLocalAssetChecksum,
i20.idxTrashedLocalAssetAlbum,
];
@override
i0.StreamQueryUpdateRules
@@ -250,6 +255,18 @@ abstract class $Drift extends i0.GeneratedDatabase {
i0.TableUpdate('remote_album_user_entity', kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'remote_asset_entity',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [
i0.TableUpdate(
'remote_asset_cloud_id_entity',
kind: i0.UpdateKind.delete,
),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'user_entity',
@@ -334,18 +351,24 @@ class $DriftManager {
);
i13.$$RemoteAlbumUserEntityTableTableManager get remoteAlbumUserEntity => i13
.$$RemoteAlbumUserEntityTableTableManager(_db, _db.remoteAlbumUserEntity);
i14.$$MemoryEntityTableTableManager get memoryEntity =>
i14.$$MemoryEntityTableTableManager(_db, _db.memoryEntity);
i15.$$MemoryAssetEntityTableTableManager get memoryAssetEntity =>
i15.$$MemoryAssetEntityTableTableManager(_db, _db.memoryAssetEntity);
i16.$$PersonEntityTableTableManager get personEntity =>
i16.$$PersonEntityTableTableManager(_db, _db.personEntity);
i17.$$AssetFaceEntityTableTableManager get assetFaceEntity =>
i17.$$AssetFaceEntityTableTableManager(_db, _db.assetFaceEntity);
i18.$$StoreEntityTableTableManager get storeEntity =>
i18.$$StoreEntityTableTableManager(_db, _db.storeEntity);
i19.$$TrashedLocalAssetEntityTableTableManager get trashedLocalAssetEntity =>
i19.$$TrashedLocalAssetEntityTableTableManager(
i14.$$RemoteAssetCloudIdEntityTableTableManager
get remoteAssetCloudIdEntity =>
i14.$$RemoteAssetCloudIdEntityTableTableManager(
_db,
_db.remoteAssetCloudIdEntity,
);
i15.$$MemoryEntityTableTableManager get memoryEntity =>
i15.$$MemoryEntityTableTableManager(_db, _db.memoryEntity);
i16.$$MemoryAssetEntityTableTableManager get memoryAssetEntity =>
i16.$$MemoryAssetEntityTableTableManager(_db, _db.memoryAssetEntity);
i17.$$PersonEntityTableTableManager get personEntity =>
i17.$$PersonEntityTableTableManager(_db, _db.personEntity);
i18.$$AssetFaceEntityTableTableManager get assetFaceEntity =>
i18.$$AssetFaceEntityTableTableManager(_db, _db.assetFaceEntity);
i19.$$StoreEntityTableTableManager get storeEntity =>
i19.$$StoreEntityTableTableManager(_db, _db.storeEntity);
i20.$$TrashedLocalAssetEntityTableTableManager get trashedLocalAssetEntity =>
i20.$$TrashedLocalAssetEntityTableTableManager(
_db,
_db.trashedLocalAssetEntity,
);

View File

@@ -6409,6 +6409,7 @@ final class Schema16 extends i0.VersionedSchema {
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxRemoteAssetOwnerChecksum,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
@@ -6419,6 +6420,7 @@ final class Schema16 extends i0.VersionedSchema {
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
@@ -6426,7 +6428,6 @@ final class Schema16 extends i0.VersionedSchema {
storeEntity,
trashedLocalAssetEntity,
idxLatLng,
idxRemoteAlbumAssetAlbumId,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
];
@@ -6489,7 +6490,7 @@ final class Schema16 extends i0.VersionedSchema {
),
alias: null,
);
late final Shape24 localAssetEntity = Shape24(
late final Shape26 localAssetEntity = Shape26(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
@@ -6507,6 +6508,7 @@ final class Schema16 extends i0.VersionedSchema {
_column_22,
_column_14,
_column_23,
_column_98,
_column_96,
_column_46,
_column_47,
@@ -6570,6 +6572,10 @@ final class Schema16 extends i0.VersionedSchema {
'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 idxRemoteAssetOwnerChecksum = i1.Index(
'idx_remote_asset_owner_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
@@ -6686,6 +6692,24 @@ final class Schema16 extends i0.VersionedSchema {
),
alias: null,
);
late final Shape27 remoteAssetCloudIdEntity = Shape27(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_99,
_column_100,
_column_96,
_column_46,
_column_47,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape11 memoryEntity = Shape11(
source: i0.VersionedTable(
entityName: 'memory_entity',
@@ -6805,10 +6829,6 @@ final class Schema16 extends i0.VersionedSchema {
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxRemoteAlbumAssetAlbumId = i1.Index(
'idx_remote_album_asset_album_id',
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_id ON remote_album_asset_entity (album_id)',
);
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)',
@@ -6819,6 +6839,78 @@ final class Schema16 extends i0.VersionedSchema {
);
}
class Shape26 extends i0.VersionedTable {
Shape26({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<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
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 durationInSeconds =>
columnsByName['duration_in_seconds']! 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<bool> get isFavorite =>
columnsByName['is_favorite']! as i1.GeneratedColumn<bool>;
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<DateTime> get adjustmentTime =>
columnsByName['adjustment_time']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<double> get latitude =>
columnsByName['latitude']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<double> get longitude =>
columnsByName['longitude']! as i1.GeneratedColumn<double>;
}
i1.GeneratedColumn<String> _column_98(String aliasedName) =>
i1.GeneratedColumn<String>(
'i_cloud_id',
aliasedName,
true,
type: i1.DriftSqlType.string,
);
class Shape27 extends i0.VersionedTable {
Shape27({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get assetId =>
columnsByName['asset_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get cloudId =>
columnsByName['cloud_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get adjustmentTime =>
columnsByName['adjustment_time']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<double> get latitude =>
columnsByName['latitude']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<double> get longitude =>
columnsByName['longitude']! as i1.GeneratedColumn<double>;
}
i1.GeneratedColumn<String> _column_99(String aliasedName) =>
i1.GeneratedColumn<String>(
'cloud_id',
aliasedName,
true,
type: i1.DriftSqlType.string,
);
i1.GeneratedColumn<DateTime> _column_100(String aliasedName) =>
i1.GeneratedColumn<DateTime>(
'created_at',
aliasedName,
true,
type: i1.DriftSqlType.dateTime,
);
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,

View File

@@ -246,6 +246,25 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
return query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get();
}
Future<void> updateCloudMapping(Map<String, String> cloudMapping) {
if (cloudMapping.isEmpty) {
return Future.value();
}
return _db.batch((batch) {
for (final entry in cloudMapping.entries) {
final assetId = entry.key;
final cloudId = entry.value;
batch.update(
_db.localAssetEntity,
LocalAssetEntityCompanion(iCloudId: Value(cloudId)),
where: (f) => f.id.equals(assetId),
);
}
});
}
Future<void> Function(Iterable<LocalAsset>) get _upsertAssets =>
CurrentPlatform.isIOS ? _upsertAssetsDarwin : _upsertAssetsAndroid;

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/constants.dart';
@@ -172,4 +174,40 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
final rows = await query.get();
return rows.map((row) => row.readTable(_db.localAssetEntity).toDto()).toList();
}
Future<List<LocalAsset>> getEmptyCloudIdAssets() {
final query = _db.localAssetEntity.select()..where((row) => row.iCloudId.isNull());
return query.map((row) => row.toDto()).get();
}
Future<Map<String, String>> getHashMappingFromCloudId() async {
final query =
_db.localAssetEntity.selectOnly().join([
leftOuterJoin(
_db.remoteAssetCloudIdEntity,
_db.localAssetEntity.iCloudId.equalsExp(_db.remoteAssetCloudIdEntity.cloudId),
useColumns: false,
),
leftOuterJoin(
_db.remoteAssetEntity,
_db.remoteAssetCloudIdEntity.assetId.equalsExp(_db.remoteAssetEntity.id),
useColumns: false,
),
])
..addColumns([_db.localAssetEntity.id, _db.remoteAssetEntity.checksum])
..where(
_db.remoteAssetCloudIdEntity.cloudId.isNotNull() &
_db.localAssetEntity.checksum.isNull() &
((_db.remoteAssetCloudIdEntity.adjustmentTime.isExp(_db.localAssetEntity.adjustmentTime)) &
(_db.remoteAssetCloudIdEntity.latitude.isExp(_db.localAssetEntity.latitude)) &
(_db.remoteAssetCloudIdEntity.longitude.isExp(_db.localAssetEntity.longitude)) &
(_db.remoteAssetCloudIdEntity.createdAt.isExp(_db.localAssetEntity.createdAt))),
);
final mapping = await query
.map(
(row) => (assetId: row.read(_db.localAssetEntity.id)!, checksum: row.read(_db.remoteAssetEntity.checksum)!),
)
.get();
return {for (final entry in mapping) entry.assetId: entry.checksum};
}
}

View File

@@ -1,8 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
@@ -323,32 +321,26 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
}).watchSingleOrNull();
}
Future<List<String>> getSortedAlbumIds(List<String> albumIds, {required AssetDateAggregation aggregation}) async {
if (albumIds.isEmpty) return [];
Future<DateTime?> getNewestAssetTimestamp(String albumId) {
final query = _db.remoteAlbumAssetEntity.selectOnly()
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
..addColumns([_db.remoteAssetEntity.localDateTime.max()])
..join([
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
]);
final jsonIds = jsonEncode(albumIds);
final sqlAgg = aggregation == AssetDateAggregation.start ? 'MIN' : 'MAX';
return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.max())).getSingleOrNull();
}
final rows = await _db
.customSelect(
'''
SELECT
raae.album_id,
$sqlAgg(rae.local_date_time) AS asset_date
FROM json_each(?) ids
INNER JOIN remote_album_asset_entity raae
ON raae.album_id = ids.value
INNER JOIN remote_asset_entity rae
ON rae.id = raae.asset_id
GROUP BY raae.album_id
ORDER BY asset_date ASC
''',
variables: [Variable<String>(jsonIds)],
readsFrom: {_db.remoteAlbumAssetEntity, _db.remoteAssetEntity},
)
.get();
Future<DateTime?> getOldestAssetTimestamp(String albumId) {
final query = _db.remoteAlbumAssetEntity.selectOnly()
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
..addColumns([_db.remoteAssetEntity.localDateTime.min()])
..join([
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
]);
return rows.map((row) => row.read<String>('album_id')).toList();
return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.min())).getSingleOrNull();
}
Future<int> getCount() {

View File

@@ -45,6 +45,7 @@ class SyncApiRepository {
SyncRequestType.usersV1,
SyncRequestType.assetsV1,
SyncRequestType.assetExifsV1,
SyncRequestType.assetMetadataV1,
SyncRequestType.partnersV1,
SyncRequestType.partnerAssetsV1,
SyncRequestType.partnerAssetExifsV1,
@@ -148,6 +149,8 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
SyncEntityType.assetV1: SyncAssetV1.fromJson,
SyncEntityType.assetDeleteV1: SyncAssetDeleteV1.fromJson,
SyncEntityType.assetExifV1: SyncAssetExifV1.fromJson,
SyncEntityType.assetMetadataV1: SyncAssetMetadataV1.fromJson,
SyncEntityType.assetMetadataDeleteV1: SyncAssetMetadataDeleteV1.fromJson,
SyncEntityType.partnerAssetV1: SyncAssetV1.fromJson,
SyncEntityType.partnerAssetBackfillV1: SyncAssetV1.fromJson,
SyncEntityType.partnerAssetDeleteV1: SyncAssetDeleteV1.fromJson,

View File

@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/memory.model.dart';
@@ -18,6 +19,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart';
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/entities/user_metadata.entity.drift.dart';
@@ -55,6 +57,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
await _db.authUserEntity.deleteAll();
await _db.userEntity.deleteAll();
await _db.userMetadataEntity.deleteAll();
await _db.remoteAssetCloudIdEntity.deleteAll();
});
await _db.customStatement('PRAGMA foreign_keys = ON');
});
@@ -272,6 +275,50 @@ class SyncStreamRepository extends DriftDatabaseRepository {
}
}
Future<void> deleteAssetsMetadataV1(Iterable<SyncAssetMetadataDeleteV1> data) async {
try {
await _db.batch((batch) {
for (final metadata in data) {
if (metadata.key == kMobileMetadataKey) {
batch.deleteWhere(_db.remoteAssetCloudIdEntity, (row) => row.assetId.equals(metadata.assetId));
}
}
});
} catch (error, stack) {
_logger.severe('Error: deleteAssetsMetadataV1', error, stack);
rethrow;
}
}
Future<void> updateAssetsMetadataV1(Iterable<SyncAssetMetadataV1> data) async {
try {
await _db.batch((batch) {
for (final metadata in data) {
if (metadata.key == kMobileMetadataKey) {
final map = metadata.value as Map<String, Object?>;
final companion = RemoteAssetCloudIdEntityCompanion(
cloudId: Value(map['iCloudId']?.toString()),
createdAt: Value(map['createdAt'] != null ? DateTime.parse(map['createdAt'] as String) : null),
adjustmentTime: Value(
map['adjustmentTime'] != null ? DateTime.parse(map['adjustmentTime'] as String) : null,
),
latitude: Value(map['latitude'] != null ? (double.tryParse(map['latitude'] as String)) : null),
longitude: Value(map['longitude'] != null ? (double.tryParse(map['longitude'] as String)) : null),
);
batch.insert(
_db.remoteAssetCloudIdEntity,
companion.copyWith(assetId: Value(metadata.assetId)),
onConflict: DoUpdate((_) => companion),
);
}
}
});
} catch (error, stack) {
_logger.severe('Error: updateAssetsMetadataV1', error, stack);
rethrow;
}
}
Future<void> deleteAlbumsV1(Iterable<SyncAlbumDeleteV1> data) async {
try {
await _db.batch((batch) {

View File

@@ -84,6 +84,10 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
isFavorite: row.isFavorite,
durationInSeconds: row.durationInSeconds,
orientation: row.orientation,
cloudId: row.iCloudId,
latitude: row.latitude,
longitude: row.longitude,
adjustmentTime: row.adjustmentTime,
),
)
.get();

View File

@@ -10,4 +10,8 @@ class ServerVersion extends SemVer {
}
ServerVersion.fromDto(ServerVersionResponseDto dto) : super(major: dto.major, minor: dto.minor, patch: dto.patch_);
bool isAtLeast({int major = 0, int minor = 0, int patch = 0}) {
return this >= SemVer(major: major, minor: minor, patch: patch);
}
}

View File

@@ -100,7 +100,7 @@ class AppLogPage extends HookConsumerWidget {
minLeadingWidth: 10,
title: Text(
truncateLogMessage(logMessage.message, 4),
style: TextStyle(fontSize: 14.0, color: context.colorScheme.onSurface, fontFamily: "Inconsolata"),
style: TextStyle(fontSize: 14.0, color: context.colorScheme.onSurface, fontFamily: "GoogleSansCode"),
),
subtitle: Text(
"at ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)} in ${logMessage.logger}",

View File

@@ -57,7 +57,7 @@ class AppLogDetailPage extends HookConsumerWidget {
padding: const EdgeInsets.all(8.0),
child: SelectableText(
text,
style: const TextStyle(fontSize: 12.0, fontWeight: FontWeight.bold, fontFamily: "Inconsolata"),
style: const TextStyle(fontSize: 12.0, fontWeight: FontWeight.bold, fontFamily: "GoogleSansCode"),
),
),
),
@@ -88,7 +88,7 @@ class AppLogDetailPage extends HookConsumerWidget {
padding: const EdgeInsets.all(8.0),
child: SelectableText(
logger.toString(),
style: const TextStyle(fontSize: 12.0, fontWeight: FontWeight.bold, fontFamily: "Inconsolata"),
style: const TextStyle(fontSize: 12.0, fontWeight: FontWeight.bold, fontFamily: "GoogleSansCode"),
),
),
),

View File

@@ -10,7 +10,6 @@ import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:logging/logging.dart';
@@ -50,7 +49,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
final accessToken = Store.tryGet(StoreKey.accessToken);
if (accessToken != null && serverUrl != null && endpoint != null) {
final infoProvider = ref.read(serverInfoProvider.notifier);
final wsProvider = ref.read(websocketProvider.notifier);
final backgroundManager = ref.read(backgroundSyncProvider);
final backupProvider = ref.read(driftBackupProvider.notifier);
@@ -60,7 +58,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
(_) async {
try {
wsProvider.connect();
unawaited(infoProvider.getServerInfo());
if (Store.isBetaTimelineEnabled) {
bool syncSuccess = false;
@@ -75,6 +72,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
_resumeBackup(backupProvider);
}),
_resumeBackup(backupProvider),
backgroundManager.syncCloudIds(),
]);
} else {
await backgroundManager.hashAssets();

View File

@@ -234,7 +234,7 @@ class FolderPath extends StatelessWidget {
Text(
currentFolder.path,
style: TextStyle(
fontFamily: 'Inconsolata',
fontFamily: 'GoogleSansCode',
fontWeight: FontWeight.bold,
fontSize: 14,
color: context.colorScheme.onSurface.withAlpha(175),

View File

@@ -41,7 +41,7 @@ class LoginPage extends HookConsumerWidget {
style: TextStyle(
color: context.colorScheme.onSurfaceSecondary,
fontWeight: FontWeight.bold,
fontFamily: "Inconsolata",
fontFamily: "GoogleSansCode",
),
),
const Text(' '),
@@ -51,7 +51,7 @@ class LoginPage extends HookConsumerWidget {
style: TextStyle(
color: context.primaryColor,
fontWeight: FontWeight.bold,
fontFamily: "Inconsolata",
fontFamily: "GoogleSansCode",
),
),
onTap: () {

View File

@@ -270,6 +270,45 @@ class HashResult {
int get hashCode => Object.hashAll(_toList());
}
class CloudIdResult {
CloudIdResult({required this.assetId, this.error, this.cloudId});
String assetId;
String? error;
String? cloudId;
List<Object?> _toList() {
return <Object?>[assetId, error, cloudId];
}
Object encode() {
return _toList();
}
static CloudIdResult decode(Object result) {
result as List<Object?>;
return CloudIdResult(assetId: result[0]! as String, error: result[1] as String?, cloudId: result[2] as String?);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! CloudIdResult || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(encode(), other.encode());
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => Object.hashAll(_toList());
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
@@ -289,6 +328,9 @@ class _PigeonCodec extends StandardMessageCodec {
} else if (value is HashResult) {
buffer.putUint8(132);
writeValue(buffer, value.encode());
} else if (value is CloudIdResult) {
buffer.putUint8(133);
writeValue(buffer, value.encode());
} else {
super.writeValue(buffer, value);
}
@@ -305,6 +347,8 @@ class _PigeonCodec extends StandardMessageCodec {
return SyncDelta.decode(readValue(buffer)!);
case 132:
return HashResult.decode(readValue(buffer)!);
case 133:
return CloudIdResult.decode(readValue(buffer)!);
default:
return super.readValueOfType(type, buffer);
}
@@ -616,4 +660,32 @@ class NativeSyncApi {
return (pigeonVar_replyList[0] as Map<Object?, Object?>?)!.cast<String, List<PlatformAsset>>();
}
}
Future<List<CloudIdResult>> getCloudIdForAssetIds(List<String> assetIds) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetIds]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else if (pigeonVar_replyList[0] == null) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as List<Object?>?)!.cast<CloudIdResult>();
}
}
}

View File

@@ -131,6 +131,7 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
final albums = await ref.read(assetServiceProvider).getSourceAlbums(asset.id);
properties.add(_PropertyItem(label: 'Album', value: albums.map((a) => a.name).join(', ')));
if (CurrentPlatform.isIOS) {
properties.add(_PropertyItem(label: 'Cloud ID', value: asset.cloudId));
properties.add(_PropertyItem(label: 'Adjustment Time', value: asset.adjustmentTime?.toString()));
}
properties.add(

View File

@@ -527,7 +527,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
if (scaleState != PhotoViewScaleState.initial) {
ref.read(assetViewerProvider.notifier).setControls(false);
if (!dragInProgress) {
ref.read(assetViewerProvider.notifier).setControls(false);
}
ref.read(videoPlayerControlsProvider.notifier).pause();
return;
}

View File

@@ -11,7 +11,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class ThumbnailTile extends ConsumerWidget {
class ThumbnailTile extends ConsumerStatefulWidget {
const ThumbnailTile(
this.asset, {
this.size = kThumbnailResolution,
@@ -30,9 +30,22 @@ class ThumbnailTile extends ConsumerWidget {
final int? heroOffset;
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = this.asset;
final heroIndex = heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
ConsumerState<ThumbnailTile> createState() => _ThumbnailTileState();
}
class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
bool _hideIndicators = false;
bool _showSelectionContainer = false;
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
final asset = widget.asset;
final heroIndex = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
final assetContainerColor = context.isDarkTheme
? context.primaryColor.darken(amount: 0.4)
@@ -43,17 +56,32 @@ class ThumbnailTile extends ConsumerWidget {
);
final bool storageIndicator =
ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator))) && showStorageIndicator;
ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator))) && widget.showStorageIndicator;
if (isSelected) {
_showSelectionContainer = true;
}
return Stack(
children: [
Container(color: lockSelection ? context.colorScheme.surfaceContainerHighest : assetContainerColor),
Container(
color: widget.lockSelection
? context.colorScheme.surfaceContainerHighest
: _showSelectionContainer
? assetContainerColor
: Colors.transparent,
),
AnimatedContainer(
duration: Durations.short4,
curve: Curves.decelerate,
padding: EdgeInsets.all(isSelected || lockSelection ? 6 : 0),
onEnd: () {
if (!isSelected) {
_showSelectionContainer = false;
}
},
padding: EdgeInsets.all(isSelected || widget.lockSelection ? 6 : 0),
child: TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0.0, end: (isSelected || lockSelection) ? 15.0 : 0.0),
tween: Tween<double>(begin: 0.0, end: (isSelected || widget.lockSelection) ? 15.0 : 0.0),
duration: Durations.short4,
curve: Curves.decelerate,
builder: (context, value, child) {
@@ -64,44 +92,80 @@ class ThumbnailTile extends ConsumerWidget {
Positioned.fill(
child: Hero(
tag: '${asset?.heroTag ?? ''}_$heroIndex',
child: Thumbnail.fromAsset(asset: asset, size: size),
child: Thumbnail.fromAsset(asset: asset, size: widget.size),
// Placeholderbuilder used to hide indicators on first hero animation, since flightShuttleBuilder isn't called until both source and destination hero exist in widget tree.
placeholderBuilder: (context, heroSize, child) {
if (!_hideIndicators) {
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() => _hideIndicators = true);
});
}
return const SizedBox();
},
flightShuttleBuilder: (context, animation, direction, from, to) {
void animationStatusListener(AnimationStatus status) {
final heroInFlight = status == AnimationStatus.forward || status == AnimationStatus.reverse;
if (_hideIndicators != heroInFlight) {
setState(() => _hideIndicators = heroInFlight);
}
if (status == AnimationStatus.completed || status == AnimationStatus.dismissed) {
animation.removeStatusListener(animationStatusListener);
}
}
animation.addStatusListener(animationStatusListener);
return to.widget;
},
),
),
if (asset != null)
Align(
alignment: Alignment.topRight,
child: _AssetTypeIcons(asset: asset),
AnimatedOpacity(
opacity: _hideIndicators ? 0.0 : 1.0,
duration: Durations.short4,
child: Align(
alignment: Alignment.topRight,
child: _AssetTypeIcons(asset: asset),
),
),
if (storageIndicator && asset != null)
switch (asset.storage) {
AssetState.local => const Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: EdgeInsets.only(right: 10.0, bottom: 6.0),
child: _TileOverlayIcon(Icons.cloud_off_outlined),
AnimatedOpacity(
opacity: _hideIndicators ? 0.0 : 1.0,
duration: Durations.short4,
child: switch (asset.storage) {
AssetState.local => const Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: EdgeInsets.only(right: 10.0, bottom: 6.0),
child: _TileOverlayIcon(Icons.cloud_off_outlined),
),
),
),
AssetState.remote => const Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: EdgeInsets.only(right: 10.0, bottom: 6.0),
child: _TileOverlayIcon(Icons.cloud_outlined),
AssetState.remote => const Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: EdgeInsets.only(right: 10.0, bottom: 6.0),
child: _TileOverlayIcon(Icons.cloud_outlined),
),
),
),
AssetState.merged => const Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: EdgeInsets.only(right: 10.0, bottom: 6.0),
child: _TileOverlayIcon(Icons.cloud_done_outlined),
AssetState.merged => const Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: EdgeInsets.only(right: 10.0, bottom: 6.0),
child: _TileOverlayIcon(Icons.cloud_done_outlined),
),
),
),
},
},
),
if (asset != null && asset.isFavorite)
const Align(
alignment: Alignment.bottomLeft,
child: Padding(
padding: EdgeInsets.only(left: 10.0, bottom: 6.0),
child: _TileOverlayIcon(Icons.favorite_rounded),
AnimatedOpacity(
duration: Durations.short4,
opacity: _hideIndicators ? 0.0 : 1.0,
child: const Align(
alignment: Alignment.bottomLeft,
child: Padding(
padding: EdgeInsets.only(left: 10.0, bottom: 6.0),
child: _TileOverlayIcon(Icons.favorite_rounded),
),
),
),
],
@@ -109,19 +173,19 @@ class ThumbnailTile extends ConsumerWidget {
),
),
TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0.0, end: (isSelected || lockSelection) ? 1.0 : 0.0),
tween: Tween<double>(begin: 0.0, end: (isSelected || widget.lockSelection) ? 1.0 : 0.0),
duration: Durations.short4,
curve: Curves.decelerate,
builder: (context, value, child) {
return Padding(
padding: EdgeInsets.all((isSelected || lockSelection) ? value * 3.0 : 3.0),
padding: EdgeInsets.all((isSelected || widget.lockSelection) ? value * 3.0 : 3.0),
child: Align(
alignment: Alignment.topLeft,
child: Opacity(
opacity: (isSelected || lockSelection) ? 1 : value,
opacity: (isSelected || widget.lockSelection) ? 1 : value,
child: _SelectionIndicator(
isLocked: lockSelection,
color: lockSelection ? context.colorScheme.surfaceContainerHighest : assetContainerColor,
isLocked: widget.lockSelection,
color: widget.lockSelection ? context.colorScheme.surfaceContainerHighest : assetContainerColor,
),
),
),

View File

@@ -450,7 +450,7 @@ class _SegmentWidget extends StatelessWidget {
alignment: Alignment.center,
child: Text(
_segment.date.year.toString(),
style: context.textTheme.labelMedium?.copyWith(fontFamily: "OverpassMono", fontWeight: FontWeight.w600),
style: context.textTheme.labelMedium?.copyWith(fontFamily: "GoogleSansCode", fontWeight: FontWeight.w600),
),
),
),

View File

@@ -160,6 +160,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
_resumeBackup();
}),
_resumeBackup(),
backgroundManager.syncCloudIds(),
]);
} else {
await _safeRun(backgroundManager.hashAssets(), "hashAssets");

View File

@@ -14,19 +14,19 @@ import 'package:immich_mobile/services/auth.service.dart';
import 'package:immich_mobile/services/secure_storage.service.dart';
import 'package:immich_mobile/services/upload.service.dart';
import 'package:immich_mobile/services/widget.service.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/utils/debug_print.dart';
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
return AuthNotifier(
ref.watch(authServiceProvider),
ref.watch(apiServiceProvider),
ref.watch(userServiceProvider),
ref.watch(uploadServiceProvider),
ref.watch(secureStorageServiceProvider),
ref.watch(widgetServiceProvider),
ref,
);
});
@@ -34,9 +34,9 @@ class AuthNotifier extends StateNotifier<AuthState> {
final AuthService _authService;
final ApiService _apiService;
final UserService _userService;
final UploadService _uploadService;
final SecureStorageService _secureStorageService;
final WidgetService _widgetService;
final Ref _ref;
final _log = Logger("AuthenticationNotifier");
static const Duration _timeoutDuration = Duration(seconds: 7);
@@ -45,9 +45,9 @@ class AuthNotifier extends StateNotifier<AuthState> {
this._authService,
this._apiService,
this._userService,
this._uploadService,
this._secureStorageService,
this._widgetService,
this._ref,
) : super(
const AuthState(
deviceId: "",
@@ -87,7 +87,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
await _widgetService.clearCredentials();
await _authService.logout();
await _uploadService.cancelBackup();
await _ref.read(uploadServiceProvider).cancelBackup();
} finally {
await _cleanUp();
}

View File

@@ -28,6 +28,9 @@ final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) {
onHashingStart: syncStatusNotifier.startHashJob,
onHashingComplete: syncStatusNotifier.completeHashJob,
onHashingError: syncStatusNotifier.errorHashJob,
onCloudIdSyncStart: syncStatusNotifier.startCloudIdSync,
onCloudIdSyncComplete: syncStatusNotifier.completeCloudIdSync,
onCloudIdSyncError: syncStatusNotifier.errorCloudIdSync,
);
ref.onDispose(manager.cancel);
return manager;

View File

@@ -32,6 +32,7 @@ final syncStreamRepositoryProvider = Provider((ref) => SyncStreamRepository(ref.
final localSyncServiceProvider = Provider(
(ref) => LocalSyncService(
localAlbumRepository: ref.watch(localAlbumRepository),
localAssetRepository: ref.watch(localAssetRepository),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
storageRepository: ref.watch(storageRepositoryProvider),

View File

@@ -32,10 +32,11 @@ class ServerInfoNotifier extends StateNotifier<ServerInfo> {
final ServerInfoService _serverInfoService;
final _log = Logger("ServerInfoNotifier");
Future<void> getServerInfo() async {
Future<ServerInfo> getServerInfo() async {
await getServerVersion();
await getServerFeatures();
await getServerConfig();
return state;
}
Future<void> getServerVersion() async {

View File

@@ -21,6 +21,7 @@ class SyncStatusState {
final SyncStatus remoteSyncStatus;
final SyncStatus localSyncStatus;
final SyncStatus hashJobStatus;
final SyncStatus cloudIdSyncStatus;
final String? errorMessage;
@@ -28,6 +29,7 @@ class SyncStatusState {
this.remoteSyncStatus = SyncStatus.idle,
this.localSyncStatus = SyncStatus.idle,
this.hashJobStatus = SyncStatus.idle,
this.cloudIdSyncStatus = SyncStatus.idle,
this.errorMessage,
});
@@ -35,12 +37,14 @@ class SyncStatusState {
SyncStatus? remoteSyncStatus,
SyncStatus? localSyncStatus,
SyncStatus? hashJobStatus,
SyncStatus? cloudIdSyncStatus,
String? errorMessage,
}) {
return SyncStatusState(
remoteSyncStatus: remoteSyncStatus ?? this.remoteSyncStatus,
localSyncStatus: localSyncStatus ?? this.localSyncStatus,
hashJobStatus: hashJobStatus ?? this.hashJobStatus,
cloudIdSyncStatus: cloudIdSyncStatus ?? this.cloudIdSyncStatus,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@@ -48,6 +52,7 @@ class SyncStatusState {
bool get isRemoteSyncing => remoteSyncStatus == SyncStatus.syncing;
bool get isLocalSyncing => localSyncStatus == SyncStatus.syncing;
bool get isHashing => hashJobStatus == SyncStatus.syncing;
bool get isCloudIdSyncing => cloudIdSyncStatus == SyncStatus.syncing;
@override
bool operator ==(Object other) {
@@ -56,11 +61,12 @@ class SyncStatusState {
other.remoteSyncStatus == remoteSyncStatus &&
other.localSyncStatus == localSyncStatus &&
other.hashJobStatus == hashJobStatus &&
other.cloudIdSyncStatus == cloudIdSyncStatus &&
other.errorMessage == errorMessage;
}
@override
int get hashCode => Object.hash(remoteSyncStatus, localSyncStatus, hashJobStatus, errorMessage);
int get hashCode => Object.hash(remoteSyncStatus, localSyncStatus, hashJobStatus, cloudIdSyncStatus, errorMessage);
}
class SyncStatusNotifier extends Notifier<SyncStatusState> {
@@ -71,6 +77,7 @@ class SyncStatusNotifier extends Notifier<SyncStatusState> {
remoteSyncStatus: SyncStatus.idle,
localSyncStatus: SyncStatus.idle,
hashJobStatus: SyncStatus.idle,
cloudIdSyncStatus: SyncStatus.idle,
);
}
@@ -109,6 +116,18 @@ class SyncStatusNotifier extends Notifier<SyncStatusState> {
void startHashJob() => setHashJobStatus(SyncStatus.syncing);
void completeHashJob() => setHashJobStatus(SyncStatus.success);
void errorHashJob(String error) => setHashJobStatus(SyncStatus.error, error);
///
/// Cloud ID Sync Job
///
void setCloudIdSyncStatus(SyncStatus status, [String? errorMessage]) {
state = state.copyWith(cloudIdSyncStatus: status, errorMessage: status == SyncStatus.error ? errorMessage : null);
}
void startCloudIdSync() => setCloudIdSyncStatus(SyncStatus.syncing);
void completeCloudIdSync() => setCloudIdSyncStatus(SyncStatus.success);
void errorCloudIdSync(String error) => setCloudIdSyncStatus(SyncStatus.error, error);
}
final syncStatusProvider = NotifierProvider<SyncStatusNotifier, SyncStatusState>(SyncStatusNotifier.new);

View File

@@ -7,6 +7,7 @@ import 'package:cancellation_token_http/http.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
@@ -14,10 +15,12 @@ import 'package:immich_mobile/extensions/platform_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/storage.repository.dart';
import 'package:immich_mobile/models/server_info/server_info.model.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
@@ -34,6 +37,7 @@ final uploadServiceProvider = Provider((ref) {
ref.watch(localAssetRepository),
ref.watch(appSettingsServiceProvider),
ref.watch(assetMediaRepositoryProvider),
ref.watch(serverInfoProvider),
);
ref.onDispose(service.dispose);
@@ -48,6 +52,7 @@ class UploadService {
this._localAssetRepository,
this._appSettingsService,
this._assetMediaRepository,
this._serverInfo,
) {
_uploadRepository.onUploadStatus = _onUploadCallback;
_uploadRepository.onTaskProgress = _onTaskProgressCallback;
@@ -59,6 +64,7 @@ class UploadService {
final DriftLocalAssetRepository _localAssetRepository;
final AppSettingsService _appSettingsService;
final AssetMediaRepository _assetMediaRepository;
final ServerInfo _serverInfo;
final Logger _logger = Logger('UploadService');
final StreamController<TaskStatusUpdate> _taskStatusController = StreamController<TaskStatusUpdate>.broadcast();
@@ -352,6 +358,10 @@ class UploadService {
priority: priority,
isFavorite: asset.isFavorite,
requiresWiFi: requiresWiFi,
cloudId: asset.cloudId,
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
latitude: asset.latitude?.toString(),
longitude: asset.longitude?.toString(),
);
}
@@ -383,6 +393,10 @@ class UploadService {
priority: 0, // Highest priority to get upload immediately
isFavorite: asset.isFavorite,
requiresWiFi: requiresWiFi,
cloudId: asset.cloudId,
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
latitude: asset.latitude?.toString(),
longitude: asset.longitude?.toString(),
);
}
@@ -410,6 +424,10 @@ class UploadService {
int? priority,
bool? isFavorite,
bool requiresWiFi = true,
String? cloudId,
String? adjustmentTime,
String? latitude,
String? longitude,
}) async {
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final url = Uri.parse('$serverEndpoint/assets').toString();
@@ -425,6 +443,20 @@ class UploadService {
'isFavorite': isFavorite?.toString() ?? 'false',
'duration': '0',
if (fields != null) ...fields,
// Include cloudId and eTag in metadata if available and server version supports it
if (CurrentPlatform.isIOS && cloudId != null && _serverInfo.serverVersion.isAtLeast(major: 2, minor: 4))
'metadata': jsonEncode([
RemoteAssetMetadataItem(
key: RemoteAssetMetadataKey.mobileApp,
value: RemoteAssetMobileAppMetadata(
cloudId: cloudId,
createdAt: createdAt.toIso8601String(),
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
),
),
]),
};
return UploadTask(

View File

@@ -40,7 +40,7 @@ ThemeData getThemeData({required ColorScheme colorScheme, required Locale locale
fontWeight: FontWeight.w600,
fontSize: 18,
),
backgroundColor: isDark ? colorScheme.surfaceContainer : colorScheme.surface,
backgroundColor: colorScheme.surface,
foregroundColor: colorScheme.primary,
elevation: 0,
scrolledUnderElevation: 0,
@@ -147,9 +147,9 @@ ImmichTheme decolorizeSurfaces({required ImmichTheme theme}) {
}
String? _getFontFamilyFromLocale(Locale locale) {
if (localesNotSupportedByOverpass.contains(locale)) {
if (localesNotSupportedByAppFont.contains(locale)) {
// Let Flutter use the default font
return null;
}
return 'Overpass';
return 'GoogleSans';
}

View File

@@ -58,7 +58,7 @@ class AdvancedBottomSheet extends HookConsumerWidget {
style: const TextStyle(
fontSize: 12.0,
fontWeight: FontWeight.bold,
fontFamily: "Inconsolata",
fontFamily: "GoogleSansCode",
),
showCursor: true,
),

View File

@@ -36,7 +36,7 @@ class BackupUploadProgressBar extends ConsumerWidget {
),
Text(
" ${uploadProgress.toStringAsFixed(0)}%",
style: const TextStyle(fontSize: 12, fontFamily: "OverpassMono"),
style: const TextStyle(fontSize: 12, fontFamily: "GoogleSansCode"),
),
],
),

View File

@@ -26,10 +26,10 @@ class BackupUploadStats extends ConsumerWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(uploadFileProgress, style: const TextStyle(fontSize: 10, fontFamily: "OverpassMono")),
Text(uploadFileProgress, style: const TextStyle(fontSize: 10, fontFamily: "GoogleSansCode")),
Text(
_formatUploadFileSpeed(uploadFileSpeed),
style: const TextStyle(fontSize: 10, fontFamily: "OverpassMono"),
style: const TextStyle(fontSize: 10, fontFamily: "GoogleSansCode"),
),
],
),

View File

@@ -50,6 +50,10 @@ class ImmichSliverAppBar extends ConsumerWidget {
duration: Durations.medium1,
opacity: isMultiSelectEnabled ? 0 : 1,
sliver: SliverAppBar(
backgroundColor: context.colorScheme.surface,
surfaceTintColor: context.colorScheme.surfaceTint,
elevation: 0,
scrolledUnderElevation: 1.0,
floating: floating,
pinned: pinned,
snap: snap,

View File

@@ -43,7 +43,7 @@ class PinInput extends StatelessWidget {
final defaultPinTheme = PinTheme(
width: getPinSize().width,
height: getPinSize().height,
textStyle: TextStyle(fontSize: 24, color: context.colorScheme.onSurface, fontFamily: 'Overpass Mono'),
textStyle: TextStyle(fontSize: 24, color: context.colorScheme.onSurface, fontFamily: 'GoogleSansCode'),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(19)),
border: Border.all(color: context.colorScheme.surfaceBright),

View File

@@ -50,7 +50,7 @@ class EntityCountTile extends StatelessWidget {
const Spacer(),
RichText(
text: TextSpan(
style: const TextStyle(fontSize: 18, fontFamily: 'OverpassMono', fontWeight: FontWeight.w600),
style: const TextStyle(fontSize: 18, fontFamily: 'GoogleSansCode', fontWeight: FontWeight.w600),
children: [
TextSpan(
text: zeroPadding(count, maxDigits),

View File

@@ -194,7 +194,7 @@ class _SyncStatusIcon extends StatelessWidget {
@override
Widget build(BuildContext context) {
return switch (status) {
SyncStatus.idle => const Icon(Icons.pause_circle_outline_rounded),
SyncStatus.idle => const SizedBox.shrink(),
SyncStatus.syncing => const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 2)),
SyncStatus.success => const Icon(Icons.check_circle_outline, color: Colors.green),
SyncStatus.error => Icon(Icons.error_outline, color: context.colorScheme.error),

View File

@@ -117,7 +117,7 @@ class EndpointInputState extends ConsumerState<EndpointInput> {
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: validateUrl,
keyboardType: TextInputType.url,
style: const TextStyle(fontFamily: 'Inconsolata', fontWeight: FontWeight.w600, fontSize: 14),
style: const TextStyle(fontFamily: 'GoogleSansCode', fontWeight: FontWeight.w600, fontSize: 14),
decoration: InputDecoration(
hintText: 'http(s)://immich.domain.com',
contentPadding: const EdgeInsets.all(16),

View File

@@ -155,7 +155,7 @@ class LocalNetworkPreference extends HookConsumerWidget {
style: context.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
color: enabled ? context.primaryColor : context.colorScheme.onSurface.withAlpha(100),
fontFamily: 'Inconsolata',
fontFamily: 'GoogleSansCode',
),
),
trailing: IconButton(
@@ -175,7 +175,7 @@ class LocalNetworkPreference extends HookConsumerWidget {
style: context.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
color: enabled ? context.primaryColor : context.colorScheme.onSurface.withAlpha(100),
fontFamily: 'Inconsolata',
fontFamily: 'GoogleSansCode',
),
),
trailing: IconButton(

View File

@@ -110,7 +110,7 @@ class NetworkingSettings extends HookConsumerWidget {
currentEndpoint ?? "--",
style: TextStyle(
fontSize: 16,
fontFamily: 'Inconsolata',
fontFamily: 'GoogleSansCode',
fontWeight: FontWeight.bold,
color: context.primaryColor,
),

View File

@@ -90,6 +90,14 @@ class HashResult {
const HashResult({required this.assetId, this.error, this.hash});
}
class CloudIdResult {
final String assetId;
final String? error;
final String? cloudId;
const CloudIdResult({required this.assetId, this.error, this.cloudId});
}
@HostApi()
abstract class NativeSyncApi {
bool shouldFullSync();
@@ -121,4 +129,7 @@ abstract class NativeSyncApi {
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
Map<String, List<PlatformAsset>> getTrashedAssets();
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds);
}

View File

@@ -127,24 +127,26 @@ flutter:
assets:
- assets/
fonts:
- family: Inconsolata
- family: GoogleSans
fonts:
- asset: fonts/Inconsolata-Regular.ttf
- family: Overpass
fonts:
- asset: fonts/overpass/Overpass-Regular.ttf
- asset: fonts/GoogleSans/GoogleSans-Regular.ttf
weight: 400
- asset: fonts/overpass/Overpass-Italic.ttf
- asset: fonts/GoogleSans/GoogleSans-Italic.ttf
style: italic
- asset: fonts/overpass/Overpass-Medium.ttf
- asset: fonts/GoogleSans/GoogleSans-Medium.ttf
weight: 500
- asset: fonts/overpass/Overpass-SemiBold.ttf
- asset: fonts/GoogleSans/GoogleSans-SemiBold.ttf
weight: 600
- asset: fonts/overpass/Overpass-Bold.ttf
- asset: fonts/GoogleSans/GoogleSans-Bold.ttf
weight: 700
- family: OverpassMono
- family: GoogleSansCode
fonts:
- asset: fonts/overpass/OverpassMono.ttf
- asset: fonts/GoogleSansCode/GoogleSansCode-Regular.ttf
weight: 400
- asset: fonts/GoogleSansCode/GoogleSansCode-Medium.ttf
weight: 500
- asset: fonts/GoogleSansCode/GoogleSansCode-SemiBold.ttf
weight: 600
flutter_launcher_icons:
image_path_android: 'assets/immich-logo.png'
adaptive_icon_background: '#ffffff'

View File

@@ -1,5 +1,4 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
@@ -14,6 +13,38 @@ void main() {
late DriftRemoteAlbumRepository mockRemoteAlbumRepo;
late DriftAlbumApiRepository mockAlbumApiRepo;
setUp(() {
mockRemoteAlbumRepo = MockRemoteAlbumRepository();
mockAlbumApiRepo = MockDriftAlbumApiRepository();
sut = RemoteAlbumService(mockRemoteAlbumRepo, mockAlbumApiRepo);
when(() => mockRemoteAlbumRepo.getNewestAssetTimestamp(any())).thenAnswer((invocation) {
// Simulate a timestamp for the newest asset in the album
final albumID = invocation.positionalArguments[0] as String;
if (albumID == '1') {
return Future.value(DateTime(2023, 1, 1));
} else if (albumID == '2') {
return Future.value(DateTime(2023, 2, 1));
}
return Future.value(DateTime.fromMillisecondsSinceEpoch(0));
});
when(() => mockRemoteAlbumRepo.getOldestAssetTimestamp(any())).thenAnswer((invocation) {
// Simulate a timestamp for the oldest asset in the album
final albumID = invocation.positionalArguments[0] as String;
if (albumID == '1') {
return Future.value(DateTime(2019, 1, 1));
} else if (albumID == '2') {
return Future.value(DateTime(2019, 2, 1));
}
return Future.value(DateTime.fromMillisecondsSinceEpoch(0));
});
});
final albumA = RemoteAlbum(
id: '1',
name: 'Album A',
@@ -42,31 +73,47 @@ void main() {
isShared: false,
);
setUp(() {
mockRemoteAlbumRepo = MockRemoteAlbumRepository();
mockAlbumApiRepo = MockDriftAlbumApiRepository();
sut = RemoteAlbumService(mockRemoteAlbumRepo, mockAlbumApiRepo);
when(
() => mockRemoteAlbumRepo.getSortedAlbumIds(any(), aggregation: AssetDateAggregation.end),
).thenAnswer((_) async => ['1', '2']);
when(
() => mockRemoteAlbumRepo.getSortedAlbumIds(any(), aggregation: AssetDateAggregation.start),
).thenAnswer((_) async => ['1', '2']);
});
group('sortAlbums', () {
test('should sort correctly based on name', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, AlbumSortMode.title);
expect(result, [albumA, albumB]);
});
test('should sort correctly based on createdAt', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, AlbumSortMode.created);
expect(result, [albumA, albumB]);
});
test('should sort correctly based on updatedAt', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, AlbumSortMode.lastModified);
expect(result, [albumA, albumB]);
});
test('should sort correctly based on assetCount', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, AlbumSortMode.assetCount);
expect(result, [albumA, albumB]);
});
test('should sort correctly based on newestAssetTimestamp', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, AlbumSortMode.mostRecent);
expect(result, [albumA, albumB]);
});
test('should sort correctly based on oldestAssetTimestamp', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, AlbumSortMode.mostOldest);
expect(result, [albumA, albumB]);
expect(result, [albumB, albumA]);
});
});
}

View File

@@ -33,6 +33,7 @@ void main() {
registerFallbackValue(LocalAssetStub.image1);
registerFallbackValue(<String, String>{});
when(() => mockAssetRepo.getHashMappingFromCloudId()).thenAnswer((_) async => {});
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
});

View File

@@ -9,6 +9,7 @@ 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/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.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/trashed_local_asset.repository.dart';
@@ -25,6 +26,7 @@ import '../../repository.mocks.dart';
void main() {
late LocalSyncService sut;
late DriftLocalAlbumRepository mockLocalAlbumRepository;
late DriftLocalAssetRepository mockLocalAssetRepository;
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository;
late LocalFilesManagerRepository mockLocalFilesManager;
late StorageRepository mockStorageRepository;
@@ -47,6 +49,7 @@ void main() {
setUp(() async {
mockLocalAlbumRepository = MockLocalAlbumRepository();
mockLocalAssetRepository = MockLocalAssetRepository();
mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository();
mockLocalFilesManager = MockLocalFilesManagerRepository();
mockStorageRepository = MockStorageRepository();
@@ -66,6 +69,7 @@ void main() {
sut = LocalSyncService(
localAlbumRepository: mockLocalAlbumRepository,
localAssetRepository: mockLocalAssetRepository,
trashedLocalAssetRepository: mockTrashedLocalAssetRepository,
localFilesManager: mockLocalFilesManager,
storageRepository: mockStorageRepository,

View File

@@ -1486,6 +1486,13 @@ class LocalAssetEntity extends Table
requiredDuringInsert: false,
defaultValue: const CustomExpression('0'),
);
late final GeneratedColumn<String> iCloudId = GeneratedColumn<String>(
'i_cloud_id',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
late final GeneratedColumn<DateTime> adjustmentTime =
GeneratedColumn<DateTime>(
'adjustment_time',
@@ -1521,6 +1528,7 @@ class LocalAssetEntity extends Table
checksum,
isFavorite,
orientation,
iCloudId,
adjustmentTime,
latitude,
longitude,
@@ -1580,6 +1588,10 @@ class LocalAssetEntity extends Table
DriftSqlType.int,
data['${effectivePrefix}orientation'],
)!,
iCloudId: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}i_cloud_id'],
),
adjustmentTime: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime,
data['${effectivePrefix}adjustment_time'],
@@ -1619,6 +1631,7 @@ class LocalAssetEntityData extends DataClass
final String? checksum;
final bool isFavorite;
final int orientation;
final String? iCloudId;
final DateTime? adjustmentTime;
final double? latitude;
final double? longitude;
@@ -1634,6 +1647,7 @@ class LocalAssetEntityData extends DataClass
this.checksum,
required this.isFavorite,
required this.orientation,
this.iCloudId,
this.adjustmentTime,
this.latitude,
this.longitude,
@@ -1660,6 +1674,9 @@ class LocalAssetEntityData extends DataClass
}
map['is_favorite'] = Variable<bool>(isFavorite);
map['orientation'] = Variable<int>(orientation);
if (!nullToAbsent || iCloudId != null) {
map['i_cloud_id'] = Variable<String>(iCloudId);
}
if (!nullToAbsent || adjustmentTime != null) {
map['adjustment_time'] = Variable<DateTime>(adjustmentTime);
}
@@ -1689,6 +1706,7 @@ class LocalAssetEntityData extends DataClass
checksum: serializer.fromJson<String?>(json['checksum']),
isFavorite: serializer.fromJson<bool>(json['isFavorite']),
orientation: serializer.fromJson<int>(json['orientation']),
iCloudId: serializer.fromJson<String?>(json['iCloudId']),
adjustmentTime: serializer.fromJson<DateTime?>(json['adjustmentTime']),
latitude: serializer.fromJson<double?>(json['latitude']),
longitude: serializer.fromJson<double?>(json['longitude']),
@@ -1709,6 +1727,7 @@ class LocalAssetEntityData extends DataClass
'checksum': serializer.toJson<String?>(checksum),
'isFavorite': serializer.toJson<bool>(isFavorite),
'orientation': serializer.toJson<int>(orientation),
'iCloudId': serializer.toJson<String?>(iCloudId),
'adjustmentTime': serializer.toJson<DateTime?>(adjustmentTime),
'latitude': serializer.toJson<double?>(latitude),
'longitude': serializer.toJson<double?>(longitude),
@@ -1727,6 +1746,7 @@ class LocalAssetEntityData extends DataClass
Value<String?> checksum = const Value.absent(),
bool? isFavorite,
int? orientation,
Value<String?> iCloudId = const Value.absent(),
Value<DateTime?> adjustmentTime = const Value.absent(),
Value<double?> latitude = const Value.absent(),
Value<double?> longitude = const Value.absent(),
@@ -1744,6 +1764,7 @@ class LocalAssetEntityData extends DataClass
checksum: checksum.present ? checksum.value : this.checksum,
isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation,
iCloudId: iCloudId.present ? iCloudId.value : this.iCloudId,
adjustmentTime: adjustmentTime.present
? adjustmentTime.value
: this.adjustmentTime,
@@ -1769,6 +1790,7 @@ class LocalAssetEntityData extends DataClass
orientation: data.orientation.present
? data.orientation.value
: this.orientation,
iCloudId: data.iCloudId.present ? data.iCloudId.value : this.iCloudId,
adjustmentTime: data.adjustmentTime.present
? data.adjustmentTime.value
: this.adjustmentTime,
@@ -1791,6 +1813,7 @@ class LocalAssetEntityData extends DataClass
..write('checksum: $checksum, ')
..write('isFavorite: $isFavorite, ')
..write('orientation: $orientation, ')
..write('iCloudId: $iCloudId, ')
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude')
@@ -1811,6 +1834,7 @@ class LocalAssetEntityData extends DataClass
checksum,
isFavorite,
orientation,
iCloudId,
adjustmentTime,
latitude,
longitude,
@@ -1830,6 +1854,7 @@ class LocalAssetEntityData extends DataClass
other.checksum == this.checksum &&
other.isFavorite == this.isFavorite &&
other.orientation == this.orientation &&
other.iCloudId == this.iCloudId &&
other.adjustmentTime == this.adjustmentTime &&
other.latitude == this.latitude &&
other.longitude == this.longitude);
@@ -1847,6 +1872,7 @@ class LocalAssetEntityCompanion extends UpdateCompanion<LocalAssetEntityData> {
final Value<String?> checksum;
final Value<bool> isFavorite;
final Value<int> orientation;
final Value<String?> iCloudId;
final Value<DateTime?> adjustmentTime;
final Value<double?> latitude;
final Value<double?> longitude;
@@ -1862,6 +1888,7 @@ class LocalAssetEntityCompanion extends UpdateCompanion<LocalAssetEntityData> {
this.checksum = const Value.absent(),
this.isFavorite = const Value.absent(),
this.orientation = const Value.absent(),
this.iCloudId = const Value.absent(),
this.adjustmentTime = const Value.absent(),
this.latitude = const Value.absent(),
this.longitude = const Value.absent(),
@@ -1878,6 +1905,7 @@ class LocalAssetEntityCompanion extends UpdateCompanion<LocalAssetEntityData> {
this.checksum = const Value.absent(),
this.isFavorite = const Value.absent(),
this.orientation = const Value.absent(),
this.iCloudId = const Value.absent(),
this.adjustmentTime = const Value.absent(),
this.latitude = const Value.absent(),
this.longitude = const Value.absent(),
@@ -1896,6 +1924,7 @@ class LocalAssetEntityCompanion extends UpdateCompanion<LocalAssetEntityData> {
Expression<String>? checksum,
Expression<bool>? isFavorite,
Expression<int>? orientation,
Expression<String>? iCloudId,
Expression<DateTime>? adjustmentTime,
Expression<double>? latitude,
Expression<double>? longitude,
@@ -1912,6 +1941,7 @@ class LocalAssetEntityCompanion extends UpdateCompanion<LocalAssetEntityData> {
if (checksum != null) 'checksum': checksum,
if (isFavorite != null) 'is_favorite': isFavorite,
if (orientation != null) 'orientation': orientation,
if (iCloudId != null) 'i_cloud_id': iCloudId,
if (adjustmentTime != null) 'adjustment_time': adjustmentTime,
if (latitude != null) 'latitude': latitude,
if (longitude != null) 'longitude': longitude,
@@ -1930,6 +1960,7 @@ class LocalAssetEntityCompanion extends UpdateCompanion<LocalAssetEntityData> {
Value<String?>? checksum,
Value<bool>? isFavorite,
Value<int>? orientation,
Value<String?>? iCloudId,
Value<DateTime?>? adjustmentTime,
Value<double?>? latitude,
Value<double?>? longitude,
@@ -1946,6 +1977,7 @@ class LocalAssetEntityCompanion extends UpdateCompanion<LocalAssetEntityData> {
checksum: checksum ?? this.checksum,
isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation,
iCloudId: iCloudId ?? this.iCloudId,
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
@@ -1988,6 +2020,9 @@ class LocalAssetEntityCompanion extends UpdateCompanion<LocalAssetEntityData> {
if (orientation.present) {
map['orientation'] = Variable<int>(orientation.value);
}
if (iCloudId.present) {
map['i_cloud_id'] = Variable<String>(iCloudId.value);
}
if (adjustmentTime.present) {
map['adjustment_time'] = Variable<DateTime>(adjustmentTime.value);
}
@@ -2014,6 +2049,7 @@ class LocalAssetEntityCompanion extends UpdateCompanion<LocalAssetEntityData> {
..write('checksum: $checksum, ')
..write('isFavorite: $isFavorite, ')
..write('orientation: $orientation, ')
..write('iCloudId: $iCloudId, ')
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude')
@@ -5333,6 +5369,348 @@ class RemoteAlbumUserEntityCompanion
}
}
class RemoteAssetCloudIdEntity extends Table
with TableInfo<RemoteAssetCloudIdEntity, RemoteAssetCloudIdEntityData> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
RemoteAssetCloudIdEntity(this.attachedDatabase, [this._alias]);
late final GeneratedColumn<String> assetId = GeneratedColumn<String>(
'asset_id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
defaultConstraints: GeneratedColumn.constraintIsAlways(
'REFERENCES remote_asset_entity (id) ON DELETE CASCADE',
),
);
late final GeneratedColumn<String> cloudId = GeneratedColumn<String>(
'cloud_id',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
late final GeneratedColumn<DateTime> createdAt = GeneratedColumn<DateTime>(
'created_at',
aliasedName,
true,
type: DriftSqlType.dateTime,
requiredDuringInsert: false,
);
late final GeneratedColumn<DateTime> adjustmentTime =
GeneratedColumn<DateTime>(
'adjustment_time',
aliasedName,
true,
type: DriftSqlType.dateTime,
requiredDuringInsert: false,
);
late final GeneratedColumn<double> latitude = GeneratedColumn<double>(
'latitude',
aliasedName,
true,
type: DriftSqlType.double,
requiredDuringInsert: false,
);
late final GeneratedColumn<double> longitude = GeneratedColumn<double>(
'longitude',
aliasedName,
true,
type: DriftSqlType.double,
requiredDuringInsert: false,
);
@override
List<GeneratedColumn> get $columns => [
assetId,
cloudId,
createdAt,
adjustmentTime,
latitude,
longitude,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'remote_asset_cloud_id_entity';
@override
Set<GeneratedColumn> get $primaryKey => {assetId};
@override
RemoteAssetCloudIdEntityData map(
Map<String, dynamic> data, {
String? tablePrefix,
}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return RemoteAssetCloudIdEntityData(
assetId: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}asset_id'],
)!,
cloudId: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}cloud_id'],
),
createdAt: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime,
data['${effectivePrefix}created_at'],
),
adjustmentTime: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime,
data['${effectivePrefix}adjustment_time'],
),
latitude: attachedDatabase.typeMapping.read(
DriftSqlType.double,
data['${effectivePrefix}latitude'],
),
longitude: attachedDatabase.typeMapping.read(
DriftSqlType.double,
data['${effectivePrefix}longitude'],
),
);
}
@override
RemoteAssetCloudIdEntity createAlias(String alias) {
return RemoteAssetCloudIdEntity(attachedDatabase, alias);
}
@override
bool get withoutRowId => true;
@override
bool get isStrict => true;
}
class RemoteAssetCloudIdEntityData extends DataClass
implements Insertable<RemoteAssetCloudIdEntityData> {
final String assetId;
final String? cloudId;
final DateTime? createdAt;
final DateTime? adjustmentTime;
final double? latitude;
final double? longitude;
const RemoteAssetCloudIdEntityData({
required this.assetId,
this.cloudId,
this.createdAt,
this.adjustmentTime,
this.latitude,
this.longitude,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['asset_id'] = Variable<String>(assetId);
if (!nullToAbsent || cloudId != null) {
map['cloud_id'] = Variable<String>(cloudId);
}
if (!nullToAbsent || createdAt != null) {
map['created_at'] = Variable<DateTime>(createdAt);
}
if (!nullToAbsent || adjustmentTime != null) {
map['adjustment_time'] = Variable<DateTime>(adjustmentTime);
}
if (!nullToAbsent || latitude != null) {
map['latitude'] = Variable<double>(latitude);
}
if (!nullToAbsent || longitude != null) {
map['longitude'] = Variable<double>(longitude);
}
return map;
}
factory RemoteAssetCloudIdEntityData.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return RemoteAssetCloudIdEntityData(
assetId: serializer.fromJson<String>(json['assetId']),
cloudId: serializer.fromJson<String?>(json['cloudId']),
createdAt: serializer.fromJson<DateTime?>(json['createdAt']),
adjustmentTime: serializer.fromJson<DateTime?>(json['adjustmentTime']),
latitude: serializer.fromJson<double?>(json['latitude']),
longitude: serializer.fromJson<double?>(json['longitude']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'assetId': serializer.toJson<String>(assetId),
'cloudId': serializer.toJson<String?>(cloudId),
'createdAt': serializer.toJson<DateTime?>(createdAt),
'adjustmentTime': serializer.toJson<DateTime?>(adjustmentTime),
'latitude': serializer.toJson<double?>(latitude),
'longitude': serializer.toJson<double?>(longitude),
};
}
RemoteAssetCloudIdEntityData copyWith({
String? assetId,
Value<String?> cloudId = const Value.absent(),
Value<DateTime?> createdAt = const Value.absent(),
Value<DateTime?> adjustmentTime = const Value.absent(),
Value<double?> latitude = const Value.absent(),
Value<double?> longitude = const Value.absent(),
}) => RemoteAssetCloudIdEntityData(
assetId: assetId ?? this.assetId,
cloudId: cloudId.present ? cloudId.value : this.cloudId,
createdAt: createdAt.present ? createdAt.value : this.createdAt,
adjustmentTime: adjustmentTime.present
? adjustmentTime.value
: this.adjustmentTime,
latitude: latitude.present ? latitude.value : this.latitude,
longitude: longitude.present ? longitude.value : this.longitude,
);
RemoteAssetCloudIdEntityData copyWithCompanion(
RemoteAssetCloudIdEntityCompanion data,
) {
return RemoteAssetCloudIdEntityData(
assetId: data.assetId.present ? data.assetId.value : this.assetId,
cloudId: data.cloudId.present ? data.cloudId.value : this.cloudId,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
adjustmentTime: data.adjustmentTime.present
? data.adjustmentTime.value
: this.adjustmentTime,
latitude: data.latitude.present ? data.latitude.value : this.latitude,
longitude: data.longitude.present ? data.longitude.value : this.longitude,
);
}
@override
String toString() {
return (StringBuffer('RemoteAssetCloudIdEntityData(')
..write('assetId: $assetId, ')
..write('cloudId: $cloudId, ')
..write('createdAt: $createdAt, ')
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(
assetId,
cloudId,
createdAt,
adjustmentTime,
latitude,
longitude,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is RemoteAssetCloudIdEntityData &&
other.assetId == this.assetId &&
other.cloudId == this.cloudId &&
other.createdAt == this.createdAt &&
other.adjustmentTime == this.adjustmentTime &&
other.latitude == this.latitude &&
other.longitude == this.longitude);
}
class RemoteAssetCloudIdEntityCompanion
extends UpdateCompanion<RemoteAssetCloudIdEntityData> {
final Value<String> assetId;
final Value<String?> cloudId;
final Value<DateTime?> createdAt;
final Value<DateTime?> adjustmentTime;
final Value<double?> latitude;
final Value<double?> longitude;
const RemoteAssetCloudIdEntityCompanion({
this.assetId = const Value.absent(),
this.cloudId = const Value.absent(),
this.createdAt = const Value.absent(),
this.adjustmentTime = const Value.absent(),
this.latitude = const Value.absent(),
this.longitude = const Value.absent(),
});
RemoteAssetCloudIdEntityCompanion.insert({
required String assetId,
this.cloudId = const Value.absent(),
this.createdAt = const Value.absent(),
this.adjustmentTime = const Value.absent(),
this.latitude = const Value.absent(),
this.longitude = const Value.absent(),
}) : assetId = Value(assetId);
static Insertable<RemoteAssetCloudIdEntityData> custom({
Expression<String>? assetId,
Expression<String>? cloudId,
Expression<DateTime>? createdAt,
Expression<DateTime>? adjustmentTime,
Expression<double>? latitude,
Expression<double>? longitude,
}) {
return RawValuesInsertable({
if (assetId != null) 'asset_id': assetId,
if (cloudId != null) 'cloud_id': cloudId,
if (createdAt != null) 'created_at': createdAt,
if (adjustmentTime != null) 'adjustment_time': adjustmentTime,
if (latitude != null) 'latitude': latitude,
if (longitude != null) 'longitude': longitude,
});
}
RemoteAssetCloudIdEntityCompanion copyWith({
Value<String>? assetId,
Value<String?>? cloudId,
Value<DateTime?>? createdAt,
Value<DateTime?>? adjustmentTime,
Value<double?>? latitude,
Value<double?>? longitude,
}) {
return RemoteAssetCloudIdEntityCompanion(
assetId: assetId ?? this.assetId,
cloudId: cloudId ?? this.cloudId,
createdAt: createdAt ?? this.createdAt,
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (assetId.present) {
map['asset_id'] = Variable<String>(assetId.value);
}
if (cloudId.present) {
map['cloud_id'] = Variable<String>(cloudId.value);
}
if (createdAt.present) {
map['created_at'] = Variable<DateTime>(createdAt.value);
}
if (adjustmentTime.present) {
map['adjustment_time'] = Variable<DateTime>(adjustmentTime.value);
}
if (latitude.present) {
map['latitude'] = Variable<double>(latitude.value);
}
if (longitude.present) {
map['longitude'] = Variable<double>(longitude.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('RemoteAssetCloudIdEntityCompanion(')
..write('assetId: $assetId, ')
..write('cloudId: $cloudId, ')
..write('createdAt: $createdAt, ')
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude')
..write(')'))
.toString();
}
}
class MemoryEntity extends Table
with TableInfo<MemoryEntity, MemoryEntityData> {
@override
@@ -7829,6 +8207,10 @@ class DatabaseAtV16 extends GeneratedDatabase {
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
late final Index idxLocalAssetCloudId = Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
late final Index idxRemoteAssetOwnerChecksum = Index(
'idx_remote_asset_owner_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
@@ -7853,6 +8235,8 @@ class DatabaseAtV16 extends GeneratedDatabase {
RemoteAlbumAssetEntity(this);
late final RemoteAlbumUserEntity remoteAlbumUserEntity =
RemoteAlbumUserEntity(this);
late final RemoteAssetCloudIdEntity remoteAssetCloudIdEntity =
RemoteAssetCloudIdEntity(this);
late final MemoryEntity memoryEntity = MemoryEntity(this);
late final MemoryAssetEntity memoryAssetEntity = MemoryAssetEntity(this);
late final PersonEntity personEntity = PersonEntity(this);
@@ -7864,10 +8248,6 @@ class DatabaseAtV16 extends GeneratedDatabase {
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
late final Index idxRemoteAlbumAssetAlbumId = Index(
'idx_remote_album_asset_album_id',
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_id ON remote_album_asset_entity (album_id)',
);
late final Index idxTrashedLocalAssetChecksum = Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
@@ -7889,6 +8269,7 @@ class DatabaseAtV16 extends GeneratedDatabase {
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxRemoteAssetOwnerChecksum,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
@@ -7899,6 +8280,7 @@ class DatabaseAtV16 extends GeneratedDatabase {
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
@@ -7906,7 +8288,6 @@ class DatabaseAtV16 extends GeneratedDatabase {
storeEntity,
trashedLocalAssetEntity,
idxLatLng,
idxRemoteAlbumAssetAlbumId,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
];

View File

@@ -1,14 +1,22 @@
import 'dart:convert';
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/store.repository.dart';
import 'package:immich_mobile/models/server_info/server_config.model.dart';
import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
import 'package:immich_mobile/models/server_info/server_features.model.dart';
import 'package:immich_mobile/models/server_info/server_info.model.dart';
import 'package:immich_mobile/models/server_info/server_version.model.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/upload.service.dart';
import 'package:mocktail/mocktail.dart';
@@ -16,8 +24,29 @@ import 'package:mocktail/mocktail.dart';
import '../domain/service.mock.dart';
import '../fixtures/asset.stub.dart';
import '../infrastructure/repository.mock.dart';
import '../repository.mocks.dart';
import '../mocks/asset_entity.mock.dart';
import '../repository.mocks.dart';
// Test ServerInfo stub
const _serverInfo = ServerInfo(
serverVersion: ServerVersion(major: 2, minor: 4, patch: 0),
latestVersion: ServerVersion(major: 2, minor: 4, patch: 0),
serverFeatures: ServerFeatures(trash: true, map: true, oauthEnabled: false, passwordLogin: true, ocr: false),
serverConfig: ServerConfig(
trashDays: 30,
oauthButtonText: 'Login with OAuth',
externalDomain: '',
mapDarkStyleUrl: '',
mapLightStyleUrl: '',
),
serverDiskInfo: ServerDiskInfo(
diskAvailable: '100GB',
diskSize: '500GB',
diskUse: '400GB',
diskUsagePercentage: 80.0,
),
versionStatus: VersionStatus.upToDate,
);
void main() {
late UploadService sut;
@@ -62,6 +91,7 @@ void main() {
mockLocalAssetRepository,
mockAppSettingsService,
mockAssetMediaRepository,
_serverInfo,
);
mockUploadRepository.onUploadStatus = (_) {};
@@ -165,4 +195,227 @@ void main() {
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
});
});
group('Server Info - cloudId and eTag metadata', () {
test('should include cloudId and eTag metadata on iOS when server version is 2.4+', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final sutWithV24 = UploadService(
mockUploadRepository,
mockBackupRepository,
mockStorageRepository,
mockLocalAssetRepository,
mockAppSettingsService,
mockAssetMediaRepository,
_serverInfo,
);
addTearDown(() => sutWithV24.dispose());
final assetWithCloudId = LocalAsset(
id: 'test-asset-id',
name: 'test.jpg',
type: AssetType.image,
createdAt: DateTime(2025, 1, 1),
updatedAt: DateTime(2025, 1, 2),
cloudId: 'cloud-id-123',
latitude: 37.7749,
longitude: -122.4194,
adjustmentTime: DateTime(2026, 1, 2),
);
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/test.jpg');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
final task = await sutWithV24.getUploadTask(assetWithCloudId);
expect(task, isNotNull);
expect(task!.fields.containsKey('metadata'), isTrue);
final metadata = jsonDecode(task.fields['metadata']!) as List;
expect(metadata, hasLength(1));
expect(metadata[0]['key'], equals('mobile-app'));
expect(metadata[0]['value']['iCloudId'], equals('cloud-id-123'));
expect(metadata[0]['value']['createdAt'], isNotNull);
expect(metadata[0]['value']['adjustmentTime'], isNotNull);
expect(metadata[0]['value']['latitude'], isNotNull);
expect(metadata[0]['value']['longitude'], isNotNull);
});
test('should NOT include metadata on iOS when server version is below 2.4', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final sutWithV23 = UploadService(
mockUploadRepository,
mockBackupRepository,
mockStorageRepository,
mockLocalAssetRepository,
mockAppSettingsService,
mockAssetMediaRepository,
_serverInfo.copyWith(
serverVersion: const ServerVersion(major: 2, minor: 3, patch: 0),
latestVersion: const ServerVersion(major: 2, minor: 3, patch: 0),
),
);
addTearDown(() => sutWithV23.dispose());
final assetWithCloudId = LocalAsset(
id: 'test-asset-id',
name: 'test.jpg',
type: AssetType.image,
createdAt: DateTime(2025, 1, 1),
updatedAt: DateTime(2025, 1, 2),
cloudId: 'cloud-id-123',
latitude: 37.7749,
longitude: -122.4194,
);
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/test.jpg');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
final task = await sutWithV23.getUploadTask(assetWithCloudId);
expect(task, isNotNull);
expect(task!.fields.containsKey('metadata'), isFalse);
});
test('should NOT include metadata on Android regardless of server version', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final sutAndroid = UploadService(
mockUploadRepository,
mockBackupRepository,
mockStorageRepository,
mockLocalAssetRepository,
mockAppSettingsService,
mockAssetMediaRepository,
_serverInfo,
);
addTearDown(() => sutAndroid.dispose());
final assetWithCloudId = LocalAsset(
id: 'test-asset-id',
name: 'test.jpg',
type: AssetType.image,
createdAt: DateTime(2025, 1, 1),
updatedAt: DateTime(2025, 1, 2),
cloudId: 'cloud-id-123',
latitude: 37.7749,
longitude: -122.4194,
);
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/test.jpg');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
final task = await sutAndroid.getUploadTask(assetWithCloudId);
expect(task, isNotNull);
expect(task!.fields.containsKey('metadata'), isFalse);
});
test('should NOT include metadata when cloudId is null even on iOS with server 2.4+', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final sutWithV24 = UploadService(
mockUploadRepository,
mockBackupRepository,
mockStorageRepository,
mockLocalAssetRepository,
mockAppSettingsService,
mockAssetMediaRepository,
_serverInfo,
);
addTearDown(() => sutWithV24.dispose());
final assetWithoutCloudId = LocalAsset(
id: 'test-asset-id',
name: 'test.jpg',
type: AssetType.image,
createdAt: DateTime(2025, 1, 1),
updatedAt: DateTime(2025, 1, 2),
cloudId: null, // No cloudId
);
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/test.jpg');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithoutCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(assetWithoutCloudId.id)).thenAnswer((_) async => mockFile);
when(
() => mockAssetMediaRepository.getOriginalFilename(assetWithoutCloudId.id),
).thenAnswer((_) async => 'test.jpg');
final task = await sutWithV24.getUploadTask(assetWithoutCloudId);
expect(task, isNotNull);
expect(task!.fields.containsKey('metadata'), isFalse);
});
test('should include metadata for live photos with cloudId on iOS 2.4+', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final sutWithV24 = UploadService(
mockUploadRepository,
mockBackupRepository,
mockStorageRepository,
mockLocalAssetRepository,
mockAppSettingsService,
mockAssetMediaRepository,
_serverInfo,
);
addTearDown(() => sutWithV24.dispose());
final assetWithCloudId = LocalAsset(
id: 'test-livephoto-id',
name: 'livephoto.heic',
type: AssetType.image,
createdAt: DateTime(2025, 1, 1),
updatedAt: DateTime(2025, 1, 2),
cloudId: 'cloud-id-livephoto',
latitude: 37.7749,
longitude: -122.4194,
);
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/livephoto.heic');
when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(
() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id),
).thenAnswer((_) async => 'livephoto.heic');
final task = await sutWithV24.getLivePhotoUploadTask(assetWithCloudId, 'video-123');
expect(task, isNotNull);
expect(task!.fields.containsKey('metadata'), isTrue);
expect(task.fields['livePhotoVideoId'], equals('video-123'));
final metadata = jsonDecode(task.fields['metadata']!) as List;
expect(metadata, hasLength(1));
expect(metadata[0]['key'], equals('mobile-app'));
expect(metadata[0]['value']['iCloudId'], equals('cloud-id-livephoto'));
});
});
}

View File

@@ -1 +1 @@
24.12.0
24.13.0

View File

@@ -28,6 +28,6 @@
"directory": "open-api/typescript-sdk"
},
"volta": {
"node": "24.12.0"
"node": "24.13.0"
}
}

1410
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1 @@
24.12.0
24.13.0

View File

@@ -167,7 +167,7 @@
"vitest": "^3.0.0"
},
"volta": {
"node": "24.12.0"
"node": "24.13.0"
},
"overrides": {
"sharp": "^0.34.5"

View File

@@ -258,6 +258,7 @@ export class MediaRepository {
colorPrimaries: stream.color_primaries,
colorSpace: stream.color_space,
colorTransfer: stream.color_transfer,
displayAspectRatio: stream.display_aspect_ratio,
})),
audioStreams: results.streams
.filter((stream) => stream.codec_type === 'audio')

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