Compare commits

..

19 Commits

Author SHA1 Message Date
shenlong-tanwen
f632e4f666 review changes 2025-09-29 05:46:09 +05:30
shenlong-tanwen
0e8492ceba trailing closures in swift 2025-09-29 03:38:29 +05:30
shenlong-tanwen
13abe14142 async ios 2025-09-29 00:10:34 +05:30
shenlong-tanwen
ae595f2947 fix: android - execute in background 2025-09-29 00:10:34 +05:30
shenlong
bea116e1b9 fix: prefer remote images in new timeline (#22452)
fix: prefer remote images in new thumbnail

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-27 21:29:28 -05:00
shenlong
cdbe1d7f10 chore: show download button for remote only assets (#22453)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-27 21:28:07 -05:00
Brandon Wees
df469cc412 feat: show motion photo icon on mobile timeline tile (#22454)
* feat: show motion photo icon on timeline tile

* chore: switch to private widget for asset type icons

* chore: small cleanup on asset type icons widget
2025-09-27 21:27:34 -05:00
shenlong
8de7eed940 feat(mobile): add unstack button (#21869)
* fix: add unstack button

* feat: allow unstacking inside of asset viewer

* chore: update tests

* chore: rework unstacking in asset viewer

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: bwees <brandonwees@gmail.com>
2025-09-28 06:51:38 +05:30
shenlong
7d8cd05bc2 fix: remote album timeline filter (#22423)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-26 17:35:46 +00:00
Brandon Wees
30a378c580 fix: local assets should not be added to album (#22304) 2025-09-26 22:41:12 +05:30
renovate[bot]
8a3684c127 chore(deps): update ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 docker digest to 41eacbe (#22305)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-26 14:26:55 +02:00
renovate[bot]
61e5c6349c chore(deps): update github-actions (#22311)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-26 14:26:47 +02:00
Mert
3bcb4b7af7 fix(mobile): scrubbing mode on scroll to date event (#22390) 2025-09-25 19:20:42 -05:00
Mert
5116b215a2 fix(mobile): load local thumbnails in album timeline (#22329)
* join local asset in album query

* missed one

* formatting
2025-09-26 00:38:19 +05:30
shenlong
c5fbbee8f6 chore: update android background worker notification text (#22347)
chore: update android bg notification text

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-26 00:22:17 +05:30
shenlong
d73aabc494 chore: log mobile upload failures (#22349)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-26 00:22:03 +05:30
shenlong
b62feb726b fix: delete temp file on iOS after upload (#22364)
fix: delete temp files on iOS after upload

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-26 00:21:25 +05:30
Brandon Wees
972e9cc039 fix: map attribution and other styling (#22303)
* chore: map widget and page styling

* fix: map bottom sheet styling

* fix: attribution location on android

it appears that on android, the attribution marker is positioned from the top of the display and on iOS its positioned from the safe area edge
2025-09-26 00:08:25 +05:30
shenlong
ee49136e97 chore: deprecate old timeline (#22328)
* chore: deprecate old timeline

* change trigger and duration

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-26 00:06:17 +05:30
76 changed files with 658 additions and 962 deletions

View File

@@ -36,7 +36,7 @@ jobs:
steps:
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0
with:
filters: |
mobile:
@@ -73,7 +73,7 @@ jobs:
- name: Restore Gradle Cache
id: cache-gradle-restore
uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: |
~/.gradle/caches
@@ -130,7 +130,7 @@ jobs:
- name: Save Gradle Cache
id: cache-gradle-save
uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
if: github.ref == 'refs/heads/main'
with:
path: |

View File

@@ -24,7 +24,7 @@ jobs:
steps:
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0
with:
filters: |
server:

View File

@@ -22,7 +22,7 @@ jobs:
steps:
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0
with:
filters: |
docs:

View File

@@ -16,7 +16,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}

View File

@@ -58,7 +58,7 @@ jobs:
- name: Generate a token
id: generate_token
if: ${{ inputs.skip != true }}
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}

View File

@@ -49,7 +49,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -111,7 +111,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}

View File

@@ -21,7 +21,7 @@ jobs:
steps:
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0
with:
filters: |
mobile:

View File

@@ -18,7 +18,7 @@ jobs:
steps:
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0
with:
filters: |
i18n:

View File

@@ -25,7 +25,7 @@ jobs:
steps:
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0
with:
filters: |
i18n:

View File

@@ -140,7 +140,7 @@ services:
database:
container_name: immich_postgres
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:c44be5f2871c59362966d71eab4268170eb6f5653c0e6170184e72b38ffdf107
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:41eacbe83eca995561fe43814fd4891e16e39632806253848efaf04d3c8a8b84
env_file:
- .env
environment:

View File

@@ -63,7 +63,7 @@ services:
database:
container_name: immich_postgres
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:c44be5f2871c59362966d71eab4268170eb6f5653c0e6170184e72b38ffdf107
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:41eacbe83eca995561fe43814fd4891e16e39632806253848efaf04d3c8a8b84
env_file:
- .env
environment:

View File

@@ -56,7 +56,7 @@ services:
database:
container_name: immich_postgres
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:c44be5f2871c59362966d71eab4268170eb6f5653c0e6170184e72b38ffdf107
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:41eacbe83eca995561fe43814fd4891e16e39632806253848efaf04d3c8a8b84
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}

View File

@@ -28,6 +28,7 @@
"add_to_album": "Add to album",
"add_to_album_bottom_sheet_added": "Added to {album}",
"add_to_album_bottom_sheet_already_exists": "Already in {album}",
"add_to_album_bottom_sheet_some_local_assets": "Some local assets could not be added to album",
"add_to_album_toggle": "Toggle selection for {album}",
"add_to_albums": "Add to albums",
"add_to_albums_count": "Add to albums ({count})",

View File

@@ -3,7 +3,6 @@ package app.alextran.immich
import android.app.Application
import androidx.work.Configuration
import androidx.work.WorkManager
import app.alextran.immich.background.BackgroundWorkerApiImpl
class ImmichApp : Application() {
override fun onCreate() {
@@ -17,6 +16,5 @@ class ImmichApp : Application() {
// As a workaround, we also run a backup check when initializing the application
ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0)
BackgroundWorkerApiImpl.enqueueBackgroundWorker(this)
}
}

View File

@@ -0,0 +1,16 @@
package app.alextran.immich
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
fun <T> dispatch(
dispatcher: CoroutineDispatcher = Dispatchers.IO,
callback: (Result<T>) -> Unit,
block: () -> T
) {
CoroutineScope(dispatcher).launch {
callback(runCatching { block() })
}
}

View File

@@ -2,49 +2,50 @@ package app.alextran.immich.background
import android.content.Context
import android.util.Log
import app.alextran.immich.dispatch
import io.flutter.embedding.engine.plugins.FlutterPlugin
import java.util.concurrent.atomic.AtomicInteger
private const val TAG = "BackgroundEngineLock"
class BackgroundEngineLock(context: Context) : BackgroundWorkerLockApi, FlutterPlugin {
private val ctx: Context = context.applicationContext
private val ctx: Context = context.applicationContext
companion object {
companion object {
private var engineCount = AtomicInteger(0)
private var engineCount = AtomicInteger(0)
private fun checkAndEnforceBackgroundLock(ctx: Context) {
// work manager task is running while the main app is opened, cancel the worker
if (BackgroundWorkerPreferences(ctx).isLocked() &&
engineCount.get() > 1 &&
BackgroundWorkerApiImpl.isBackgroundWorkerRunning()
) {
Log.i(TAG, "Background worker is locked, cancelling the background worker")
BackgroundWorkerApiImpl.cancelBackgroundWorker(ctx)
}
}
private fun checkAndEnforceBackgroundLock(ctx: Context) {
// work manager task is running while the main app is opened, cancel the worker
if (BackgroundWorkerPreferences(ctx).isLocked() &&
engineCount.get() > 1 &&
BackgroundWorkerApiImpl.isBackgroundWorkerRunning()
) {
Log.i(TAG, "Background worker is locked, cancelling the background worker")
BackgroundWorkerApiImpl.cancelBackgroundWorker(ctx)
}
}
}
override fun lock() {
BackgroundWorkerPreferences(ctx).setLocked(true)
checkAndEnforceBackgroundLock(ctx)
Log.i(TAG, "Background worker is locked")
}
override fun lock(callback: (Result<Unit>) -> Unit) = dispatch(callback = callback) {
BackgroundWorkerPreferences(ctx).setLocked(true)
checkAndEnforceBackgroundLock(ctx)
Log.i(TAG, "Background worker is locked")
}
override fun unlock() {
BackgroundWorkerPreferences(ctx).setLocked(false)
Log.i(TAG, "Background worker is unlocked")
}
override fun unlock(callback: (Result<Unit>) -> Unit) = dispatch(callback = callback) {
BackgroundWorkerPreferences(ctx).setLocked(false)
Log.i(TAG, "Background worker is unlocked")
}
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
checkAndEnforceBackgroundLock(binding.applicationContext)
engineCount.incrementAndGet()
Log.i(TAG, "Flutter engine attached. Attached Engines count: $engineCount")
}
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
checkAndEnforceBackgroundLock(binding.applicationContext)
engineCount.incrementAndGet()
Log.i(TAG, "Flutter engine attached. Attached Engines count: $engineCount")
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
engineCount.decrementAndGet()
Log.i(TAG, "Flutter engine detached. Attached Engines count: $engineCount")
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
engineCount.decrementAndGet()
Log.i(TAG, "Flutter engine detached. Attached Engines count: $engineCount")
}
}

View File

@@ -133,11 +133,12 @@ private open class BackgroundWorkerPigeonCodec : StandardMessageCodec() {
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface BackgroundWorkerFgHostApi {
fun enable()
fun configure(settings: BackgroundWorkerSettings)
fun disable()
fun enable(callback: (Result<Unit>) -> Unit)
fun configure(settings: BackgroundWorkerSettings, callback: (Result<Unit>) -> Unit)
fun disable(callback: (Result<Unit>) -> Unit)
companion object {
/** The codec used by BackgroundWorkerFgHostApi. */
@@ -152,13 +153,14 @@ interface BackgroundWorkerFgHostApi {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
api.enable()
listOf(null)
} catch (exception: Throwable) {
BackgroundWorkerPigeonUtils.wrapError(exception)
api.enable{ result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(BackgroundWorkerPigeonUtils.wrapError(error))
} else {
reply.reply(BackgroundWorkerPigeonUtils.wrapResult(null))
}
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
@@ -170,13 +172,14 @@ interface BackgroundWorkerFgHostApi {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val settingsArg = args[0] as BackgroundWorkerSettings
val wrapped: List<Any?> = try {
api.configure(settingsArg)
listOf(null)
} catch (exception: Throwable) {
BackgroundWorkerPigeonUtils.wrapError(exception)
api.configure(settingsArg) { result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(BackgroundWorkerPigeonUtils.wrapError(error))
} else {
reply.reply(BackgroundWorkerPigeonUtils.wrapResult(null))
}
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
@@ -186,13 +189,14 @@ interface BackgroundWorkerFgHostApi {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
api.disable()
listOf(null)
} catch (exception: Throwable) {
BackgroundWorkerPigeonUtils.wrapError(exception)
api.disable{ result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(BackgroundWorkerPigeonUtils.wrapError(error))
} else {
reply.reply(BackgroundWorkerPigeonUtils.wrapResult(null))
}
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)

View File

@@ -215,7 +215,7 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
if (foregroundFuture != null && !foregroundFuture.isCancelled && !foregroundFuture.isDone) {
try {
foregroundFuture.get(500, TimeUnit.MILLISECONDS)
} catch (e: Exception) {
} catch (_: Exception) {
// ignored, there is nothing to be done
}
}

View File

@@ -8,6 +8,7 @@ import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import app.alextran.immich.dispatch
import io.flutter.embedding.engine.FlutterEngineCache
import java.util.concurrent.TimeUnit
@@ -16,16 +17,18 @@ private const val TAG = "BackgroundWorkerApiImpl"
class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
private val ctx: Context = context.applicationContext
override fun enable() {
enqueueMediaObserver(ctx)
}
override fun enable(callback: (Result<Unit>) -> Unit) =
dispatch(callback = callback) { enqueueMediaObserver(ctx) }
override fun configure(settings: BackgroundWorkerSettings) {
override fun configure(
settings: BackgroundWorkerSettings,
callback: (Result<Unit>) -> Unit
) = dispatch(callback = callback) {
BackgroundWorkerPreferences(ctx).updateSettings(settings)
enqueueMediaObserver(ctx)
}
override fun disable() {
override fun disable(callback: (Result<Unit>) -> Unit) = dispatch(callback = callback) {
WorkManager.getInstance(ctx).apply {
cancelUniqueWork(OBSERVER_WORKER_NAME)
cancelUniqueWork(BACKGROUND_WORKER_NAME)
@@ -38,7 +41,6 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
private const val OBSERVER_WORKER_NAME = "immich/MediaObserverV1"
const val ENGINE_CACHE_KEY = "immich::background_worker::engine"
fun enqueueMediaObserver(ctx: Context) {
val settings = BackgroundWorkerPreferences(ctx).getSettings()
val constraints = Constraints.Builder().apply {

View File

@@ -44,10 +44,11 @@ private open class BackgroundWorkerLockPigeonCodec : StandardMessageCodec() {
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface BackgroundWorkerLockApi {
fun lock()
fun unlock()
fun lock(callback: (Result<Unit>) -> Unit)
fun unlock(callback: (Result<Unit>) -> Unit)
companion object {
/** The codec used by BackgroundWorkerLockApi. */
@@ -62,13 +63,14 @@ interface BackgroundWorkerLockApi {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerLockApi.lock$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
api.lock()
listOf(null)
} catch (exception: Throwable) {
BackgroundWorkerLockPigeonUtils.wrapError(exception)
api.lock{ result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(BackgroundWorkerLockPigeonUtils.wrapError(error))
} else {
reply.reply(BackgroundWorkerLockPigeonUtils.wrapResult(null))
}
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
@@ -78,13 +80,14 @@ interface BackgroundWorkerLockApi {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerLockApi.unlock$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
api.unlock()
listOf(null)
} catch (exception: Throwable) {
BackgroundWorkerLockPigeonUtils.wrapError(exception)
api.unlock{ result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(BackgroundWorkerLockPigeonUtils.wrapError(error))
} else {
reply.reply(BackgroundWorkerLockPigeonUtils.wrapResult(null))
}
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)

View File

@@ -82,9 +82,10 @@ private open class ConnectivityPigeonCodec : StandardMessageCodec() {
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface ConnectivityApi {
fun getCapabilities(): List<NetworkCapability>
fun getCapabilities(callback: (Result<List<NetworkCapability>>) -> Unit)
companion object {
/** The codec used by ConnectivityApi. */
@@ -100,12 +101,15 @@ interface ConnectivityApi {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ConnectivityApi.getCapabilities$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.getCapabilities())
} catch (exception: Throwable) {
ConnectivityPigeonUtils.wrapError(exception)
api.getCapabilities{ result: Result<List<NetworkCapability>> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(ConnectivityPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(ConnectivityPigeonUtils.wrapResult(data))
}
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.wifi.WifiManager
import app.alextran.immich.dispatch
class ConnectivityApiImpl(context: Context) : ConnectivityApi {
private val connectivityManager =
@@ -11,7 +12,13 @@ class ConnectivityApiImpl(context: Context) : ConnectivityApi {
private val wifiManager =
context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
override fun getCapabilities(): List<NetworkCapability> {
override fun getCapabilities(callback: (Result<List<NetworkCapability>>) -> Unit) =
dispatch(callback = callback) {
getCapabilities()
}
private fun getCapabilities(): List<NetworkCapability> {
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
?: return emptyList()

View File

@@ -296,13 +296,13 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface NativeSyncApi {
fun shouldFullSync(): Boolean
fun getMediaChanges(): SyncDelta
fun getMediaChanges(callback: (Result<SyncDelta>) -> Unit)
fun checkpointSync()
fun clearSyncCheckpoint()
fun getAssetIdsForAlbum(albumId: String): List<String>
fun getAlbums(): List<PlatformAlbum>
fun getAssetsCountSince(albumId: String, timestamp: Long): Long
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset>
fun getAssetIdsForAlbum(albumId: String, callback: (Result<List<String>>) -> Unit)
fun getAlbums(callback: (Result<List<PlatformAlbum>>) -> Unit)
fun getAssetsCountSince(albumId: String, timestamp: Long, callback: (Result<Long>) -> Unit)
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?, callback: (Result<List<PlatformAsset>>) -> Unit)
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
fun cancelHashing()
@@ -335,12 +335,15 @@ interface NativeSyncApi {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.getMediaChanges())
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
api.getMediaChanges{ result: Result<SyncDelta> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
@@ -384,12 +387,15 @@ interface NativeSyncApi {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val albumIdArg = args[0] as String
val wrapped: List<Any?> = try {
listOf(api.getAssetIdsForAlbum(albumIdArg))
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
api.getAssetIdsForAlbum(albumIdArg) { result: Result<List<String>> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
@@ -399,12 +405,15 @@ interface NativeSyncApi {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.getAlbums())
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
api.getAlbums{ result: Result<List<PlatformAlbum>> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
@@ -417,12 +426,15 @@ interface NativeSyncApi {
val args = message as List<Any?>
val albumIdArg = args[0] as String
val timestampArg = args[1] as Long
val wrapped: List<Any?> = try {
listOf(api.getAssetsCountSince(albumIdArg, timestampArg))
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
api.getAssetsCountSince(albumIdArg, timestampArg) { result: Result<Long> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
@@ -435,12 +447,15 @@ interface NativeSyncApi {
val args = message as List<Any?>
val albumIdArg = args[0] as String
val updatedTimeCondArg = args[1] as Long?
val wrapped: List<Any?> = try {
listOf(api.getAssetsForAlbum(albumIdArg, updatedTimeCondArg))
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
api.getAssetsForAlbum(albumIdArg, updatedTimeCondArg) { result: Result<List<PlatformAsset>> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)

View File

@@ -18,7 +18,7 @@ class NativeSyncApiImpl26(context: Context) : NativeSyncApiImplBase(context), Na
// No-op for Android 10 and below
}
override fun getMediaChanges(): SyncDelta {
throw IllegalStateException("Method not supported on this Android version.")
}
override fun getMediaChanges(callback: (Result<SyncDelta>) -> Unit) =
callback(Result.failure(IllegalStateException("Method not supported on this Android version.")))
}

View File

@@ -5,6 +5,7 @@ import android.os.Build
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.annotation.RequiresExtension
import app.alextran.immich.dispatch
import kotlinx.serialization.json.Json
@RequiresApi(Build.VERSION_CODES.Q)
@@ -47,7 +48,12 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
}
}
override fun getMediaChanges(): SyncDelta {
override fun getMediaChanges(callback: (Result<SyncDelta>) -> Unit) =
dispatch(callback = callback) {
getMediaChanges()
}
private fun getMediaChanges(): SyncDelta {
val genMap = getSavedGenerationMap()
val currentVolumes = MediaStore.getExternalVolumeNames(ctx)
val changed = mutableListOf<PlatformAsset>()

View File

@@ -7,6 +7,7 @@ import android.database.Cursor
import android.provider.MediaStore
import android.util.Base64
import androidx.core.database.getStringOrNull
import app.alextran.immich.dispatch
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -145,7 +146,10 @@ open class NativeSyncApiImplBase(context: Context) {
}
}
fun getAlbums(): List<PlatformAlbum> {
fun getAlbums(callback: (Result<List<PlatformAlbum>>) -> Unit) =
dispatch(callback = callback) { getAlbums() }
private fun getAlbums(): List<PlatformAlbum> {
val albums = mutableListOf<PlatformAlbum>()
val albumsCount = mutableMapOf<String, Int>()
@@ -192,7 +196,10 @@ open class NativeSyncApiImplBase(context: Context) {
.sortedBy { it.id }
}
fun getAssetIdsForAlbum(albumId: String): List<String> {
fun getAssetIdsForAlbum(albumId: String, callback: (Result<List<String>>) -> Unit) =
dispatch(callback = callback) { getAssetIdsForAlbum(albumId); }
private fun getAssetIdsForAlbum(albumId: String): List<String> {
val projection = arrayOf(MediaStore.MediaColumns._ID)
return getCursor(
@@ -208,15 +215,23 @@ open class NativeSyncApiImplBase(context: Context) {
} ?: emptyList()
}
fun getAssetsCountSince(albumId: String, timestamp: Long): Long =
fun getAssetsCountSince(albumId: String, timestamp: Long, callback: (Result<Long>) -> Unit) =
dispatch(callback = callback) { getAssetsCountSince(albumId, timestamp) }
private fun getAssetsCountSince(albumId: String, timestamp: Long): Long =
getCursor(
MediaStore.VOLUME_EXTERNAL,
"$BUCKET_SELECTION AND ${MediaStore.Files.FileColumns.DATE_ADDED} > ? AND $MEDIA_SELECTION",
arrayOf(albumId, timestamp.toString(), *MEDIA_SELECTION_ARGS),
)?.use { cursor -> cursor.count.toLong() } ?: 0L
fun getAssetsForAlbum(
albumId: String,
updatedTimeCond: Long?,
callback: (Result<List<PlatformAsset>>) -> Unit
) = dispatch(callback = callback) { getAssetsForAlbum(albumId, updatedTimeCond) }
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset> {
private fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset> {
var selection = "$BUCKET_SELECTION AND $MEDIA_SELECTION"
val selectionArgs = mutableListOf(albumId, *MEDIA_SELECTION_ARGS)
@@ -254,7 +269,7 @@ open class NativeSyncApiImplBase(context: Context) {
}.awaitAll()
callback(Result.success(results))
} catch (e: CancellationException) {
} catch (_: CancellationException) {
callback(
Result.failure(
FlutterError(

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
@@ -133,11 +133,14 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Sync;
sourceTree = "<group>";
};
B2D27ABE2E84A0FF004DD55B /* Core */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Core;
sourceTree = "<group>";
};
F0B57D3D2DF764BD00DC5BCC /* WidgetExtension */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
@@ -247,6 +250,7 @@
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
B2D27ABE2E84A0FF004DD55B /* Core */,
B25D37792E72CA15008B6CA7 /* Connectivity */,
B21E34A62E5AF9760031FDB9 /* Background */,
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
@@ -332,6 +336,7 @@
);
fileSystemSynchronizedGroups = (
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
B2D27ABE2E84A0FF004DD55B /* Core */,
);
name = Runner;
productName = Runner;
@@ -521,10 +526,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@@ -553,10 +562,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";

View File

@@ -179,11 +179,12 @@ class BackgroundWorkerPigeonCodec: FlutterStandardMessageCodec, @unchecked Senda
static let shared = BackgroundWorkerPigeonCodec(readerWriter: BackgroundWorkerPigeonCodecReaderWriter())
}
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol BackgroundWorkerFgHostApi {
func enable() throws
func configure(settings: BackgroundWorkerSettings) throws
func disable() throws
func enable(completion: @escaping (Result<Void, Error>) -> Void)
func configure(settings: BackgroundWorkerSettings, completion: @escaping (Result<Void, Error>) -> Void)
func disable(completion: @escaping (Result<Void, Error>) -> Void)
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -195,11 +196,13 @@ class BackgroundWorkerFgHostApiSetup {
let enableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
enableChannel.setMessageHandler { _, reply in
do {
try api.enable()
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
api.enable { result in
switch result {
case .success:
reply(wrapResult(nil))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
@@ -210,11 +213,13 @@ class BackgroundWorkerFgHostApiSetup {
configureChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let settingsArg = args[0] as! BackgroundWorkerSettings
do {
try api.configure(settings: settingsArg)
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
api.configure(settings: settingsArg) { result in
switch result {
case .success:
reply(wrapResult(nil))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
@@ -223,11 +228,13 @@ class BackgroundWorkerFgHostApiSetup {
let disableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
disableChannel.setMessageHandler { _, reply in
do {
try api.disable()
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
api.disable { result in
switch result {
case .success:
reply(wrapResult(nil))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {

View File

@@ -1,23 +1,27 @@
import BackgroundTasks
class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
func enable() throws {
BackgroundWorkerApiImpl.scheduleRefreshWorker()
BackgroundWorkerApiImpl.scheduleProcessingWorker()
print("BackgroundWorkerApiImpl:enable Background worker scheduled")
func enable(completion: @escaping (Result<Void, any Error>) -> Void) {
dispatch(completion: completion) {
BackgroundWorkerApiImpl.scheduleRefreshWorker()
BackgroundWorkerApiImpl.scheduleProcessingWorker()
print("BackgroundWorkerApiImpl:enable Background worker scheduled")
}
}
func configure(settings: BackgroundWorkerSettings) throws {
func configure(settings: BackgroundWorkerSettings, completion: @escaping (Result<Void, any Error>) -> Void) {
// Android only
completion(Result.success(Void()))
}
func disable() throws {
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.refreshTaskID);
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.processingTaskID);
print("BackgroundWorkerApiImpl:disableUploadWorker Disabled background workers")
func disable(completion: @escaping (Result<Void, any Error>) -> Void) {
dispatch(completion: completion) {
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.refreshTaskID);
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.processingTaskID);
print("BackgroundWorkerApiImpl:disableUploadWorker Disabled background workers")
}
}
private static let refreshTaskID = "app.alextran.immich.background.refreshUpload"
private static let processingTaskID = "app.alextran.immich.background.processingUpload"
private static let taskSemaphore = DispatchSemaphore(value: 1)

View File

@@ -94,9 +94,10 @@ class ConnectivityPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable
static let shared = ConnectivityPigeonCodec(readerWriter: ConnectivityPigeonCodecReaderWriter())
}
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol ConnectivityApi {
func getCapabilities() throws -> [NetworkCapability]
func getCapabilities(completion: @escaping (Result<[NetworkCapability], Error>) -> Void)
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -115,11 +116,13 @@ class ConnectivityApiSetup {
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ConnectivityApi.getCapabilities\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getCapabilitiesChannel.setMessageHandler { _, reply in
do {
let result = try api.getCapabilities()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
api.getCapabilities { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {

View File

@@ -1,6 +1,6 @@
class ConnectivityApiImpl: ConnectivityApi {
func getCapabilities() throws -> [NetworkCapability] {
[]
func getCapabilities(completion: @escaping (Result<[NetworkCapability], any Error>) -> Void) {
completion(Result.success([]))
}
}

View File

@@ -0,0 +1,9 @@
func dispatch<T>(
qos: DispatchQoS.QoSClass = .default,
completion: @escaping (Result<T, Error>) -> Void,
block: @escaping () throws -> T
) {
DispatchQueue.global(qos: qos).async {
completion(Result { try block() })
}
}

View File

@@ -355,13 +355,13 @@ class MessagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol NativeSyncApi {
func shouldFullSync() throws -> Bool
func getMediaChanges() throws -> SyncDelta
func getMediaChanges(completion: @escaping (Result<SyncDelta, Error>) -> Void)
func checkpointSync() throws
func clearSyncCheckpoint() throws
func getAssetIdsForAlbum(albumId: String) throws -> [String]
func getAlbums() throws -> [PlatformAlbum]
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
func getAssetIdsForAlbum(albumId: String, completion: @escaping (Result<[String], Error>) -> Void)
func getAlbums(completion: @escaping (Result<[PlatformAlbum], Error>) -> Void)
func getAssetsCountSince(albumId: String, timestamp: Int64, completion: @escaping (Result<Int64, Error>) -> Void)
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?, completion: @escaping (Result<[PlatformAsset], Error>) -> Void)
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
func cancelHashing() throws
}
@@ -395,11 +395,13 @@ class NativeSyncApiSetup {
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getMediaChangesChannel.setMessageHandler { _, reply in
do {
let result = try api.getMediaChanges()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
api.getMediaChanges { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
@@ -438,11 +440,13 @@ class NativeSyncApiSetup {
getAssetIdsForAlbumChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let albumIdArg = args[0] as! String
do {
let result = try api.getAssetIdsForAlbum(albumId: albumIdArg)
reply(wrapResult(result))
} catch {
reply(wrapError(error))
api.getAssetIdsForAlbum(albumId: albumIdArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
@@ -453,11 +457,13 @@ class NativeSyncApiSetup {
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getAlbumsChannel.setMessageHandler { _, reply in
do {
let result = try api.getAlbums()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
api.getAlbums { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
@@ -471,11 +477,13 @@ class NativeSyncApiSetup {
let args = message as! [Any?]
let albumIdArg = args[0] as! String
let timestampArg = args[1] as! Int64
do {
let result = try api.getAssetsCountSince(albumId: albumIdArg, timestamp: timestampArg)
reply(wrapResult(result))
} catch {
reply(wrapError(error))
api.getAssetsCountSince(albumId: albumIdArg, timestamp: timestampArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
@@ -489,11 +497,13 @@ class NativeSyncApiSetup {
let args = message as! [Any?]
let albumIdArg = args[0] as! String
let updatedTimeCondArg: Int64? = nilOrValue(args[1])
do {
let result = try api.getAssetsForAlbum(albumId: albumIdArg, updatedTimeCond: updatedTimeCondArg)
reply(wrapResult(result))
} catch {
reply(wrapError(error))
api.getAssetsForAlbum(albumId: albumIdArg, updatedTimeCond: updatedTimeCondArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {

View File

@@ -18,6 +18,7 @@ struct AssetWrapper: Hashable, Equatable {
}
class NativeSyncApiImpl: NativeSyncApi {
private let defaults: UserDefaults
private let changeTokenKey = "immich:changeToken"
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
@@ -75,7 +76,12 @@ class NativeSyncApiImpl: NativeSyncApi {
return false
}
func getAlbums() throws -> [PlatformAlbum] {
func getAlbums(completion: @escaping (Result<[PlatformAlbum], any Error>) -> Void) {
dispatch(qos: .userInitiated, completion: completion, block: getAlbums)
}
private func getAlbums() throws -> [PlatformAlbum] {
var albums: [PlatformAlbum] = []
albumTypes.forEach { type in
@@ -112,7 +118,11 @@ class NativeSyncApiImpl: NativeSyncApi {
return albums.sorted { $0.id < $1.id }
}
func getMediaChanges() throws -> SyncDelta {
func getMediaChanges(completion: @escaping (Result<SyncDelta, any Error>) -> Void) {
dispatch(qos: .userInitiated, completion: completion, block: getMediaChanges)
}
private func getMediaChanges() throws -> SyncDelta {
guard #available(iOS 16, *) else {
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil)
}
@@ -198,7 +208,11 @@ class NativeSyncApiImpl: NativeSyncApi {
return albumAssets
}
func getAssetIdsForAlbum(albumId: String) throws -> [String] {
func getAssetIdsForAlbum(albumId: String, completion: @escaping (Result<[String], any Error>) -> Void) {
dispatch(qos: .userInitiated, completion: completion) { try self.getAssetIdsForAlbum(albumId: albumId) }
}
private func getAssetIdsForAlbum(albumId: String) throws -> [String] {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else {
return []
@@ -214,7 +228,13 @@ class NativeSyncApiImpl: NativeSyncApi {
return ids
}
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 {
func getAssetsCountSince(albumId: String, timestamp: Int64, completion: @escaping (Result<Int64, any Error>) -> Void) {
dispatch(qos: .userInitiated, completion: completion) {
try self.getAssetsCountSince(albumId: albumId, timestamp: timestamp)
}
}
private func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else {
return 0
@@ -228,7 +248,13 @@ class NativeSyncApiImpl: NativeSyncApi {
return Int64(assets.count)
}
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] {
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?, completion: @escaping (Result<[PlatformAsset], any Error>) -> Void) {
dispatch(qos: .userInitiated, completion: completion) {
try self.getAssetsForAlbum(albumId: albumId, updatedTimeCond: updatedTimeCond)
}
}
private func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else {
return []

View File

@@ -116,7 +116,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
if (Platform.isAndroid) {
await _backgroundHostApi.showNotification(
IntlKeys.uploading_media.t(),
IntlKeys.backup_background_service_in_progress_notification.t(),
IntlKeys.backup_background_service_default_notification.t(),
);
}

View File

@@ -21,7 +21,7 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
}
extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
LocalAsset toDto() => LocalAsset(
LocalAsset toDto({String? remoteId}) => LocalAsset(
id: id,
name: name,
checksum: checksum,
@@ -32,7 +32,7 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
isFavorite: isFavorite,
height: height,
width: width,
remoteId: null,
remoteId: remoteId,
orientation: orientation,
);
}

View File

@@ -49,7 +49,7 @@ class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin
}
extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
RemoteAsset toDto() => RemoteAsset(
RemoteAsset toDto({String? localId}) => RemoteAsset(
id: id,
name: name,
ownerId: ownerId,
@@ -64,7 +64,7 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
thumbHash: thumbHash,
visibility: visibility,
livePhotoVideoId: livePhotoVideoId,
localId: null,
localId: localId,
stackId: stackId,
);
}

View File

@@ -148,10 +148,9 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)])
..limit(count, offset: offset);
return query.map((row) {
final asset = row.readTable(_db.localAssetEntity).toDto();
return asset.copyWith(remoteId: row.read(_db.remoteAssetEntity.id));
}).get();
return query
.map((row) => row.readTable(_db.localAssetEntity).toDto(remoteId: row.read(_db.remoteAssetEntity.id)))
.get();
}
TimelineQuery remoteAlbum(String albumId, GroupAssetsBy groupBy) => (
@@ -165,17 +164,15 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
.count(where: (row) => row.albumId.equals(albumId))
.map(_generateBuckets)
.watch()
.map((results) => results.isNotEmpty ? results.first : <Bucket>[])
.handleError((error) {
return [];
});
.map((results) => results.isNotEmpty ? results.first : const <Bucket>[])
.handleError((error) => const <Bucket>[]);
}
return (_db.remoteAlbumEntity.select()..where((row) => row.id.equals(albumId)))
.watch()
.switchMap((albums) {
if (albums.isEmpty) {
return Stream.value(<Bucket>[]);
return Stream.value(const <Bucket>[]);
}
final album = albums.first;
@@ -207,10 +204,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return TimeBucket(date: timeline, assetCount: assetCount);
}).watch();
})
.handleError((error) {
// If there's an error (e.g., album was deleted), return empty buckets
return <Bucket>[];
});
// If there's an error (e.g., album was deleted), return empty buckets
.handleError((error) => const <Bucket>[]);
}
Future<List<BaseAsset>> _getRemoteAlbumBucketAssets(String albumId, {required int offset, required int count}) async {
@@ -218,17 +213,22 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
// If album doesn't exist (was deleted), return empty list
if (albumData == null) {
return <BaseAsset>[];
return const <BaseAsset>[];
}
final isAscending = albumData.order == AlbumAssetOrder.asc;
final query = _db.remoteAssetEntity.select().join([
final query = _db.remoteAssetEntity.select().addColumns([_db.localAssetEntity.id]).join([
innerJoin(
_db.remoteAlbumAssetEntity,
_db.remoteAlbumAssetEntity.assetId.equalsExp(_db.remoteAssetEntity.id),
useColumns: false,
),
leftOuterJoin(
_db.localAssetEntity,
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
useColumns: false,
),
])..where(_db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAlbumAssetEntity.albumId.equals(albumId));
if (isAscending) {
@@ -239,12 +239,14 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
query.limit(count, offset: offset);
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
return query
.map((row) => row.readTable(_db.remoteAssetEntity).toDto(localId: row.read(_db.localAssetEntity.id)))
.get();
}
TimelineQuery fromAssets(List<BaseAsset> assets) => (
bucketSource: () => Stream.value(_generateBuckets(assets.length)),
assetSource: (offset, count) => Future.value(assets.skip(offset).take(count).toList()),
assetSource: (offset, count) => Future.value(assets.skip(offset).take(count).toList(growable: false)),
);
TimelineQuery remote(String ownerId, GroupAssetsBy groupBy) => _remoteQueryBuilder(
@@ -486,6 +488,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
}
@pragma('vm:prefer-inline')
TimelineQuery _remoteQueryBuilder({
required Expression<bool> Function($RemoteAssetEntityTable row) filter,
GroupAssetsBy groupBy = GroupAssetsBy.day,
@@ -523,6 +526,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
}).watch();
}
@pragma('vm:prefer-inline')
Future<List<BaseAsset>> _getRemoteAssets({
required Expression<bool> Function($RemoteAssetEntityTable row) filter,
required int offset,
@@ -543,11 +547,9 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)])
..limit(count, offset: offset);
return query.map((row) {
final asset = row.readTable(_db.remoteAssetEntity).toDto();
final localId = row.read(_db.localAssetEntity.id);
return asset.copyWith(localId: localId);
}).get();
return query
.map((row) => row.readTable(_db.remoteAssetEntity).toDto(localId: row.read(_db.localAssetEntity.id)))
.get();
} else {
final query = _db.remoteAssetEntity.select()
..where(filter)
@@ -560,12 +562,12 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
}
List<Bucket> _generateBuckets(int count) {
final buckets = List.generate(
(count / kTimelineNoneSegmentSize).floor(),
(_) => const Bucket(assetCount: kTimelineNoneSegmentSize),
final buckets = List.filled(
(count / kTimelineNoneSegmentSize).ceil(),
const Bucket(assetCount: kTimelineNoneSegmentSize),
);
if (count % kTimelineNoneSegmentSize != 0) {
buckets.add(Bucket(assetCount: count % kTimelineNoneSegmentSize));
buckets[buckets.length - 1] = Bucket(assetCount: count % kTimelineNoneSegmentSize);
}
return buckets;
}
@@ -590,10 +592,6 @@ extension on String {
GroupAssetsBy.month => "y-M",
GroupAssetsBy.none => throw ArgumentError("GroupAssetsBy.none is not supported for date formatting"),
};
try {
return DateFormat(format, 'en').parse(this);
} catch (e) {
throw FormatException("Invalid date format: $this", e);
}
return DateFormat(format, 'en').parse(this);
}
}

View File

@@ -25,9 +25,10 @@ class DriftMapPage extends StatelessWidget {
onPressed: () => context.pop(),
icon: const Icon(Icons.arrow_back_ios_new_rounded),
style: IconButton.styleFrom(
shape: const CircleBorder(side: BorderSide(width: 1, color: Colors.black26)),
padding: const EdgeInsets.all(8),
backgroundColor: Colors.indigo.withValues(alpha: 0.7),
backgroundColor: Colors.indigo,
shadowColor: Colors.black26,
elevation: 4,
),
),
),

View File

@@ -36,7 +36,7 @@ class UnStackActionButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.filter_none_rounded,
iconData: Icons.layers_clear_outlined,
label: "unstack".t(context: context),
onPressed: () => _onTap(context, ref),
);

View File

@@ -51,6 +51,7 @@ class AssetDetailBottomSheet extends ConsumerWidget {
isArchived: isArchived,
isTrashEnabled: isTrashEnable,
isInLockedView: isInLockedView,
isStacked: asset.hasRemote && (asset as RemoteAsset).stackId != null,
currentAlbum: currentAlbum,
advancedTroubleshooting: advancedTroubleshooting,
source: ActionSource.viewer,

View File

@@ -57,7 +57,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final actions = <Widget>[
if (asset.hasRemote) const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
if (asset.isRemoteOnly) const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
if (album != null && album.isActivityEnabled && album.isShared)
IconButton(

View File

@@ -13,6 +13,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_act
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
@@ -44,6 +45,7 @@ class ArchiveBottomSheet extends ConsumerWidget {
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
],
if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline),

View File

@@ -13,6 +13,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_act
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
@@ -44,6 +45,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
],
if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline),

View File

@@ -5,6 +5,7 @@ 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/setting.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
@@ -19,6 +20,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
@@ -62,11 +64,19 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
return;
}
final remoteAssets = selectedAssets.whereType<RemoteAsset>();
final addedCount = await ref
.read(remoteAlbumProvider.notifier)
.addAssets(album.id, selectedAssets.map((e) => (e as RemoteAsset).id).toList());
.addAssets(album.id, remoteAssets.map((e) => e.id).toList());
if (addedCount != selectedAssets.length) {
if (selectedAssets.length != remoteAssets.length) {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_some_local_assets'.t(context: context),
);
}
if (addedCount != remoteAssets.length) {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {"album": album.name}),
@@ -108,15 +118,18 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
const DeleteActionButton(source: ActionSource.timeline),
],
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
if (multiselect.hasLocal) const UploadActionButton(source: ActionSource.timeline),
],
slivers: [
const AddToAlbumHeader(),
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
],
slivers: multiselect.hasRemote
? [
const AddToAlbumHeader(),
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
]
: [],
);
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
@@ -10,13 +11,14 @@ class MapBottomSheet extends StatelessWidget {
@override
Widget build(BuildContext context) {
return const BaseBottomSheet(
return BaseBottomSheet(
initialChildSize: 0.25,
maxChildSize: 0.9,
shouldCloseOnMinExtent: false,
resizeOnScroll: false,
actions: [],
slivers: [SliverFillRemaining(hasScrollBody: false, child: _ScopedMapTimeline())],
backgroundColor: context.themeData.colorScheme.surface,
slivers: [const SliverFillRemaining(hasScrollBody: false, child: _ScopedMapTimeline())],
);
}
}

View File

@@ -17,6 +17,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
@@ -102,6 +103,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
],
if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline),

View File

@@ -123,28 +123,14 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
return provider;
}
ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Size size = kThumbnailResolution}) {
assert(asset != null || remoteId != null, 'Either asset or remoteId must be provided');
if (remoteId != null) {
return RemoteThumbProvider(assetId: remoteId);
}
if (_shouldUseLocalAsset(asset!)) {
ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution}) {
if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
return LocalThumbProvider(id: id, size: size, assetType: asset.type);
}
final String assetId;
if (asset is LocalAsset && asset.hasRemote) {
assetId = asset.remoteId!;
} else if (asset is RemoteAsset) {
assetId = asset.id;
} else {
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
}
return RemoteThumbProvider(assetId: assetId);
final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId;
return assetId != null ? RemoteThumbProvider(assetId: assetId) : null;
}
bool _shouldUseLocalAsset(BaseAsset asset) =>

View File

@@ -5,7 +5,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
@@ -39,14 +38,7 @@ class Thumbnail extends StatefulWidget {
),
_ => null,
},
imageProvider = switch (asset) {
RemoteAsset() =>
asset.localId == null
? RemoteThumbProvider(assetId: asset.id)
: LocalThumbProvider(id: asset.localId!, size: size, assetType: asset.type),
LocalAsset() => LocalThumbProvider(id: asset.id, size: size, assetType: asset.type),
_ => null,
};
imageProvider = asset == null ? null : getThumbnailImageProvider(asset, size: size);
@override
State<Thumbnail> createState() => _ThumbnailState();

View File

@@ -54,8 +54,6 @@ class ThumbnailTile extends ConsumerWidget {
)
: const BoxDecoration();
final hasStack = asset is RemoteAsset && asset.stackId != null;
final bool storageIndicator =
showStorageIndicator ?? ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator)));
@@ -77,21 +75,10 @@ class ThumbnailTile extends ConsumerWidget {
child: Thumbnail.fromAsset(asset: asset, size: size),
),
),
if (hasStack)
if (asset != null)
Align(
alignment: Alignment.topRight,
child: Padding(
padding: EdgeInsets.only(right: 10.0, top: asset.isVideo ? 24.0 : 6.0),
child: const _TileOverlayIcon(Icons.burst_mode_rounded),
),
),
if (asset != null && asset.isVideo)
Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.only(right: 10.0, top: 6.0),
child: _VideoIndicator(asset.duration),
),
child: _AssetTypeIcons(asset: asset),
),
if (storageIndicator && asset != null)
switch (asset.storage) {
@@ -214,3 +201,34 @@ class _TileOverlayIcon extends StatelessWidget {
);
}
}
class _AssetTypeIcons extends StatelessWidget {
final BaseAsset asset;
const _AssetTypeIcons({required this.asset});
@override
Widget build(BuildContext context) {
final hasStack = asset is RemoteAsset && (asset as RemoteAsset).stackId != null;
final isLivePhoto = asset is RemoteAsset && asset.livePhotoVideoId != null;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (asset.isVideo)
Padding(padding: const EdgeInsets.only(right: 10.0, top: 6.0), child: _VideoIndicator(asset.duration)),
if (hasStack)
const Padding(
padding: EdgeInsets.only(right: 10.0, top: 6.0),
child: _TileOverlayIcon(Icons.burst_mode_rounded),
),
if (isLivePhoto)
const Padding(
padding: EdgeInsets.only(right: 10.0, top: 6.0),
child: _TileOverlayIcon(Icons.motion_photos_on_rounded),
),
],
);
}
}

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
@@ -9,8 +10,8 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/map/map_utils.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/presentation/widgets/map/map_utils.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -187,6 +188,8 @@ class _Map extends StatelessWidget {
styleString: style,
onMapCreated: onMapCreated,
onStyleLoadedCallback: onMapReady,
attributionButtonPosition: AttributionButtonPosition.topRight,
attributionButtonMargins: Platform.isIOS ? const Point(40, 12) : const Point(40, 72),
),
),
);

View File

@@ -212,11 +212,14 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
if (fallbackSegment != null) {
// Scroll to the segment with a small offset to show the header
final targetOffset = fallbackSegment.startOffset - 50;
_scrollController.animateTo(
targetOffset.clamp(0.0, _scrollController.position.maxScrollExtent),
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
);
ref.read(timelineStateProvider.notifier).setScrubbing(true);
_scrollController
.animateTo(
targetOffset.clamp(0.0, _scrollController.position.maxScrollExtent),
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
)
.whenComplete(() => ref.read(timelineStateProvider.notifier).setScrubbing(false));
}
});
}

View File

@@ -1,6 +1,5 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:async';
import 'dart:convert';
import 'package:background_downloader/background_downloader.dart';
import 'package:collection/collection.dart';
@@ -12,8 +11,8 @@ import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/upload.service.dart';
import 'package:logging/logging.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:logging/logging.dart';
class EnqueueStatus {
final int enqueueCount;
@@ -90,33 +89,6 @@ class DriftUploadStatus {
networkSpeedAsString.hashCode ^
isFailed.hashCode;
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'taskId': taskId,
'filename': filename,
'progress': progress,
'fileSize': fileSize,
'networkSpeedAsString': networkSpeedAsString,
'isFailed': isFailed,
};
}
factory DriftUploadStatus.fromMap(Map<String, dynamic> map) {
return DriftUploadStatus(
taskId: map['taskId'] as String,
filename: map['filename'] as String,
progress: map['progress'] as double,
fileSize: map['fileSize'] as int,
networkSpeedAsString: map['networkSpeedAsString'] as String,
isFailed: map['isFailed'] != null ? map['isFailed'] as bool : null,
);
}
String toJson() => json.encode(toMap());
factory DriftUploadStatus.fromJson(String source) =>
DriftUploadStatus.fromMap(json.decode(source) as Map<String, dynamic>);
}
class DriftBackupState {
@@ -267,6 +239,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
state = state.copyWith(uploadItems: {...state.uploadItems, taskId: currentItem.copyWith(isFailed: true)});
_logger.fine("Upload failed for taskId: $taskId, exception: ${update.exception}");
break;
case TaskStatus.canceled:

View File

@@ -3,7 +3,10 @@ import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@@ -36,6 +39,7 @@ class ActionNotifier extends Notifier<void> {
late ActionService _service;
late UploadService _uploadService;
late DownloadService _downloadService;
late AssetService _assetService;
ActionNotifier() : super();
@@ -43,6 +47,7 @@ class ActionNotifier extends Notifier<void> {
void build() {
_uploadService = ref.watch(uploadServiceProvider);
_service = ref.watch(actionServiceProvider);
_assetService = ref.watch(assetServiceProvider);
_downloadService = ref.watch(downloadServiceProvider);
_downloadService.onImageDownloadStatus = _downloadImageCallback;
_downloadService.onVideoDownloadStatus = _downloadVideoCallback;
@@ -335,6 +340,14 @@ class ActionNotifier extends Notifier<void> {
final assets = _getOwnedRemoteAssetsForSource(source);
try {
await _service.unStack(assets.map((e) => e.stackId).nonNulls.toList());
if (source == ActionSource.viewer) {
final updatedParent = await _assetService.getRemoteAsset(assets.first.id);
if (updatedParent != null) {
ref.read(currentAssetNotifier.notifier).setAsset(updatedParent);
ref.read(assetViewerProvider.notifier).setAsset(updatedParent);
}
}
return ActionResult(count: assets.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to unstack assets', error, stack);

View File

@@ -28,6 +28,8 @@ class MultiSelectState {
bool get hasRemote =>
selectedAssets.any((asset) => asset.storage == AssetState.remote || asset.storage == AssetState.merged);
bool get hasStacked => selectedAssets.any((asset) => asset is RemoteAsset && asset.stackId != null);
bool get hasLocal => selectedAssets.any((asset) => asset.storage == AssetState.local);
bool get hasMerged => selectedAssets.any((asset) => asset.storage == AssetState.merged);

View File

@@ -9,6 +9,7 @@ import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
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';
@@ -19,9 +20,9 @@ import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
import 'package:immich_mobile/utils/debug_print.dart';
final uploadServiceProvider = Provider((ref) {
final service = UploadService(
@@ -205,10 +206,20 @@ class UploadService {
return _uploadRepository.start();
}
void _handleTaskStatusUpdate(TaskStatusUpdate update) {
void _handleTaskStatusUpdate(TaskStatusUpdate update) async {
switch (update.status) {
case TaskStatus.complete:
_handleLivePhoto(update);
if (CurrentPlatform.isIOS) {
try {
final path = await update.task.filePath();
await File(path).delete();
} catch (e) {
_logger.severe('Error deleting file path for iOS: $e');
}
}
break;
default:

View File

@@ -16,6 +16,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
class ActionButtonContext {
@@ -24,6 +25,7 @@ class ActionButtonContext {
final bool isArchived;
final bool isTrashEnabled;
final bool isInLockedView;
final bool isStacked;
final RemoteAlbum? currentAlbum;
final bool advancedTroubleshooting;
final ActionSource source;
@@ -33,6 +35,7 @@ class ActionButtonContext {
required this.isOwner,
required this.isArchived,
required this.isTrashEnabled,
required this.isStacked,
required this.isInLockedView,
required this.currentAlbum,
required this.advancedTroubleshooting,
@@ -55,6 +58,7 @@ enum ActionButtonType {
deleteLocal,
upload,
removeFromAlbum,
unstack,
likeActivity;
bool shouldShow(ActionButtonContext context) {
@@ -110,6 +114,10 @@ enum ActionButtonType {
context.isOwner && //
!context.isInLockedView && //
context.currentAlbum != null,
ActionButtonType.unstack =>
context.isOwner && //
!context.isInLockedView && //
context.isStacked,
ActionButtonType.likeActivity =>
!context.isInLockedView &&
context.currentAlbum != null &&
@@ -138,28 +146,13 @@ enum ActionButtonType {
source: context.source,
),
ActionButtonType.likeActivity => const LikeActivityActionButton(),
ActionButtonType.unstack => UnStackActionButton(source: context.source),
};
}
}
class ActionButtonBuilder {
static const List<ActionButtonType> _actionTypes = [
ActionButtonType.advancedInfo,
ActionButtonType.share,
ActionButtonType.shareLink,
ActionButtonType.likeActivity,
ActionButtonType.archive,
ActionButtonType.unarchive,
ActionButtonType.download,
ActionButtonType.trash,
ActionButtonType.deletePermanent,
ActionButtonType.delete,
ActionButtonType.moveToLockFolder,
ActionButtonType.removeFromLockFolder,
ActionButtonType.deleteLocal,
ActionButtonType.upload,
ActionButtonType.removeFromAlbum,
];
static const List<ActionButtonType> _actionTypes = ActionButtonType.values;
static List<Widget> build(ActionButtonContext context) {
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();

View File

@@ -129,19 +129,24 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
title: Builder(
builder: (BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Builder(
builder: (context) {
return Padding(
padding: const EdgeInsets.only(top: 3.0),
child: SvgPicture.asset(
context.isDarkTheme
? 'assets/immich-logo-inline-dark.svg'
: 'assets/immich-logo-inline-light.svg',
height: 40,
),
);
},
Padding(
padding: const EdgeInsets.only(top: 3.0),
child: SvgPicture.asset(
context.isDarkTheme ? 'assets/immich-logo-inline-dark.svg' : 'assets/immich-logo-inline-light.svg',
height: 40,
),
),
const Tooltip(
triggerMode: TooltipTriggerMode.tap,
showDuration: Duration(seconds: 4),
message:
"The old timeline is deprecated and will be removed in a future release. Kindly switch to the new timeline under Advanced Settings.",
child: Padding(
padding: EdgeInsets.only(top: 3.0),
child: Icon(Icons.error_rounded, fill: 1, color: Colors.amber, size: 20),
),
),
],
);

View File

@@ -20,10 +20,13 @@ class BackgroundWorkerSettings {
@HostApi()
abstract class BackgroundWorkerFgHostApi {
@async
void enable();
@async
void configure(BackgroundWorkerSettings settings);
@async
void disable();
}

View File

@@ -11,7 +11,9 @@ import 'package:pigeon/pigeon.dart';
)
@HostApi()
abstract class BackgroundWorkerLockApi {
@async
void lock();
@async
void unlock();
}

View File

@@ -15,6 +15,7 @@ enum NetworkCapability { cellular, wifi, vpn, unmetered }
@HostApi()
abstract class ConnectivityApi {
@async
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
List<NetworkCapability> getCapabilities();
}

View File

@@ -83,6 +83,7 @@ class HashResult {
abstract class NativeSyncApi {
bool shouldFullSync();
@async
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
SyncDelta getMediaChanges();
@@ -90,15 +91,19 @@ abstract class NativeSyncApi {
void clearSyncCheckpoint();
@async
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
List<String> getAssetIdsForAlbum(String albumId);
@async
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
List<PlatformAlbum> getAlbums();
@async
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
int getAssetsCountSince(String albumId, int timestamp);
@async
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
List<PlatformAsset> getAssetsForAlbum(String albumId, {int? updatedTimeCond});

View File

@@ -82,6 +82,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -112,6 +113,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -127,6 +129,7 @@ void main() {
isInLockedView: true,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -145,6 +148,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -161,6 +165,7 @@ void main() {
isInLockedView: true,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -177,6 +182,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -195,6 +201,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -211,6 +218,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -227,6 +235,7 @@ void main() {
isInLockedView: true,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -243,6 +252,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -259,6 +269,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -277,6 +288,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -293,6 +305,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -309,6 +322,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -327,6 +341,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -343,6 +358,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -359,6 +375,7 @@ void main() {
isInLockedView: true,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -377,6 +394,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -393,6 +411,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -411,6 +430,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -427,6 +447,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -445,6 +466,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -463,6 +485,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -481,6 +504,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -497,6 +521,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -512,6 +537,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -530,6 +556,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -548,6 +575,7 @@ void main() {
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -563,6 +591,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -581,6 +610,7 @@ void main() {
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -597,6 +627,7 @@ void main() {
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -613,6 +644,7 @@ void main() {
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -628,6 +660,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -645,6 +678,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: true,
isStacked: false,
source: ActionSource.timeline,
);
@@ -660,6 +694,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -668,6 +703,59 @@ void main() {
});
});
group('unstack button', () {
test('should show when owner, not locked, has remote, and is stacked', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: true,
source: ActionSource.timeline,
);
expect(ActionButtonType.unstack.shouldShow(context), isTrue);
});
test('should not show when not stacked', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.unstack.shouldShow(context), isFalse);
});
test('should not show when not owner', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: false,
isArchived: true,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.unstack.shouldShow(context), isFalse);
});
});
group('ActionButtonType.buildButton', () {
late BaseAsset asset;
late ActionButtonContext context;
@@ -682,6 +770,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
});
@@ -698,6 +787,22 @@ void main() {
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>());
} else if (buttonType == ActionButtonType.unstack) {
final album = createRemoteAlbum();
final contextWithAlbum = ActionButtonContext(
asset: asset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: true,
source: ActionSource.timeline,
);
final widget = buttonType.buildButton(contextWithAlbum);
@@ -721,6 +826,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -741,6 +847,7 @@ void main() {
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -759,6 +866,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -778,6 +886,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -791,6 +900,7 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);

View File

@@ -1,21 +1,4 @@
FROM ghcr.io/immich-app/base-server-dev:202509210934@sha256:b5ce2d7eaf379d4cf15efd4bab180d8afc8a80d20b36c9800f4091aca6ae267e AS builder
ARG TUSD_RELEASE=v2.8.0
ARG TUSD_AMD64_SHA256=33aaa15e14c7b025772d28605c6e0733ec2e8c1d6b646fa6b83af0049022a9ce
ARG TUSD_ARM64_SHA256=07f78ee2fd552296c40f3a7a5a06325a06b53f1dd1ba996a60ecc52696906481
# TODO: move this to base image
RUN apt-get update && apt-get install -y curl && \
if [ $(arch) = "x86_64" ]; then \
curl -fsSL -o /tmp/tusd.deb "https://github.com/tus/tusd/releases/download/${TUSD_RELEASE}/tusd_snapshot_amd64.deb" && \
echo "${TUSD_AMD64_SHA256} /tmp/tusd.deb" | sha256sum -c; \
else \
curl -fsSL -o /tmp/tusd.deb "https://github.com/tus/tusd/releases/download/${TUSD_RELEASE}/tusd_snapshot_arm64.deb" && \
echo "${TUSD_ARM64_SHA256} /tmp/tusd.deb" | sha256sum -c; \
fi && \
dpkg -i /tmp/tusd.deb && \
rm -f /tmp/tusd.deb
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp
@@ -57,7 +40,6 @@ ENV NODE_ENV=production \
NVIDIA_DRIVER_CAPABILITIES=all \
NVIDIA_VISIBLE_DEVICES=all
COPY --from=builder /usr/bin/tusd /usr/bin/tusd
COPY --from=server /output/server-pruned ./server
COPY --from=web /usr/src/app/web/build /build/www
COPY --from=cli /output/cli-pruned ./cli

View File

@@ -1,21 +1,6 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:202509210934@sha256:b5ce2d7eaf379d4cf15efd4bab180d8afc8a80d20b36c9800f4091aca6ae267e AS dev
ARG TUSD_RELEASE=v2.8.0
ARG TUSD_AMD64_SHA256=33aaa15e14c7b025772d28605c6e0733ec2e8c1d6b646fa6b83af0049022a9ce
ARG TUSD_ARM64_SHA256=07f78ee2fd552296c40f3a7a5a06325a06b53f1dd1ba996a60ecc52696906481
# TODO: move this to base image
RUN if [ $(arch) = "x86_64" ]; then \
curl -fsSL -o /tmp/tusd.deb "https://github.com/tus/tusd/releases/download/${TUSD_RELEASE}/tusd_snapshot_amd64.deb" && \
echo "${TUSD_AMD64_SHA256} /tmp/tusd.deb" | sha256sum -c; \
else \
curl -fsSL -o /tmp/tusd.deb "https://github.com/tus/tusd/releases/download/${TUSD_RELEASE}/tusd_snapshot_arm64.deb" && \
echo "${TUSD_ARM64_SHA256} /tmp/tusd.deb" | sha256sum -c; \
fi && \
dpkg -i /tmp/tusd.deb && \
rm -f /tmp/tusd.deb
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp

View File

@@ -28,6 +28,7 @@ import { getKyselyConfig } from 'src/utils/database';
const common = [...repositories, ...services, GlobalExceptionFilter];
export const middleware = [
FileUploadInterceptor,
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
{ provide: APP_PIPE, useValue: new ValidationPipe({ transform: true, whitelist: true }) },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
@@ -84,7 +85,7 @@ class BaseModule implements OnModuleInit, OnModuleDestroy {
@Module({
imports: [...imports, ScheduleModule.forRoot()],
controllers: [...controllers],
providers: [...common, ...middleware, FileUploadInterceptor, { provide: IWorker, useValue: ImmichWorker.Api }],
providers: [...common, ...middleware, { provide: IWorker, useValue: ImmichWorker.Api }],
})
export class ApiModule extends BaseModule {}

View File

@@ -16,11 +16,6 @@ export const ADDED_IN_PREFIX = 'This property was added in ';
export const JOBS_ASSET_PAGINATION_SIZE = 1000;
export const JOBS_LIBRARY_PAGINATION_SIZE = 10_000;
export const UPLOAD_TUSD_SOCKET_PATH = '/tmp/immich-tusd.sock';
export const UPLOAD_CHUNK_DIRECTORY = '/tmp/immich-chunked-uploads';
export const UPLOAD_TUSD_CONNECT_TIMEOUT_MS = 1000;
export const UPLOAD_TUSD_CONNECT_BACKOFF_MS = 100;
export const UPLOAD_TUSD_CONNECT_RETRIES = 30;
export const EXTENSION_NAMES: Record<DatabaseExtension, string> = {
cube: 'cube',

View File

@@ -1,82 +0,0 @@
import { All, BadRequestException, Body, Controller, HttpException, Post, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { Request, Response } from 'express';
import {
TusdHookRequestDto,
TusdHookRequestType,
TusdHookResponseDto,
TusdPreCreateEventDto,
TusdPreFinishEventDto,
} from 'src/dtos/upload.dto';
import { Permission } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { AssetUploadService } from 'src/services/asset-upload.service';
import { AuthService } from 'src/services/auth.service';
@ApiTags('Upload')
@Controller('upload')
export class AssetUploadController {
constructor(
private authService: AuthService,
private uploadService: AssetUploadService,
private logger: LoggingRepository,
) {
logger.setContext(AssetUploadController.name);
}
// Proxies chunked upload requests to the tusd server.
// Auth is handled in the pre-create and pre-finish hooks from tusd.
@All('asset{/*all}')
handleChunks(@Req() request: Request, @Res() response: Response): Promise<unknown> {
return this.uploadService.proxyChunks(request, response);
}
// This controller handles webhooks from the tusd server.
// See https://tus.github.io/tusd/advanced-topics/hooks/ for more information.
@Post('hook')
async processHook(@Body() payload: TusdHookRequestDto): Promise<TusdHookResponseDto> {
const request = payload.Event.HTTPRequest;
const lowerCaseHeaders: Record<string, string> = {};
for (const [key, [value]] of Object.entries(request.Header)) {
lowerCaseHeaders[key.toLowerCase()] = value;
}
try {
const auth = await this.authService.authenticate({
headers: lowerCaseHeaders,
queryParams: {},
metadata: { adminRoute: false, sharedLinkRoute: false, permission: Permission.AssetUpload, uri: request.URI },
});
switch (payload.Type) {
case TusdHookRequestType.PreCreate: {
const dto = plainToInstance(TusdPreCreateEventDto, payload.Event);
const errors = validateSync(dto, { whitelist: true });
if (errors.length > 0) {
throw new BadRequestException('Invalid payload');
}
return await this.uploadService.handlePreCreate(auth, dto, lowerCaseHeaders);
}
case TusdHookRequestType.PreFinish: {
const dto = plainToInstance(TusdPreFinishEventDto, payload.Event);
const errors = validateSync(dto, { whitelist: true });
if (errors.length > 0) {
throw new BadRequestException('Invalid payload');
}
return await this.uploadService.handlePreFinish(auth, dto);
}
default: {
throw new Error(`Unhandled hook type: ${payload.Type}`);
}
}
} catch (error: any) {
if (error instanceof HttpException) {
return { RejectUpload: true, HTTPResponse: { StatusCode: error.getStatus(), Body: error.message } };
}
this.logger.error('Error processing upload hook', error);
return { RejectUpload: true, HTTPResponse: { StatusCode: 500 } };
}
}
}

View File

@@ -3,7 +3,6 @@ import { AlbumController } from 'src/controllers/album.controller';
import { ApiKeyController } from 'src/controllers/api-key.controller';
import { AppController } from 'src/controllers/app.controller';
import { AssetMediaController } from 'src/controllers/asset-media.controller';
import { AssetUploadController } from 'src/controllers/asset-upload.controller';
import { AssetController } from 'src/controllers/asset.controller';
import { AuthAdminController } from 'src/controllers/auth-admin.controller';
import { AuthController } from 'src/controllers/auth.controller';
@@ -41,7 +40,6 @@ export const controllers = [
AppController,
AssetController,
AssetMediaController,
AssetUploadController,
AuthController,
AuthAdminController,
DownloadController,

View File

@@ -1,133 +0,0 @@
import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsNotEmpty, IsObject, IsString, IsUUID, ValidateNested } from 'class-validator';
import { AssetMediaCreateDto } from 'src/dtos/asset-media.dto';
export enum TusdHookRequestType {
PreCreate = 'pre-create',
PreFinish = 'pre-finish',
}
export enum TusdHookStorageType {
FileStore = 'filestore',
}
export class TusdStorageDto {
@IsEnum(TusdHookStorageType)
Type!: string;
@IsString()
@IsNotEmpty()
Path!: string;
@IsString()
@IsNotEmpty()
InfoPath!: string;
}
export class UploadAssetDataDto extends AssetMediaCreateDto {
@IsString()
@IsNotEmpty()
declare filename: string;
}
export class TusdMetaDataDto {
@IsString()
@IsNotEmpty()
declare AssetData: string; // base64-encoded JSON string of UploadAssetDataDto
}
export class TusdPreCreateUploadDto {
@IsInt()
Size!: number;
}
export class TusdPreFinishUploadDto {
@IsUUID()
ID!: string;
@IsInt()
Size!: number;
@Type(() => TusdMetaDataDto)
@ValidateNested()
@IsObject()
MetaData!: TusdMetaDataDto;
@Type(() => TusdStorageDto)
@ValidateNested()
@IsObject()
Storage!: TusdStorageDto;
}
export class TusdHttpRequestDto {
@IsString()
@IsNotEmpty()
Method!: string;
@IsString()
@IsNotEmpty()
URI!: string;
@IsObject()
Header!: Record<string, string[]>;
}
export class TusdPreCreateEventDto {
@Type(() => TusdPreCreateUploadDto)
@ValidateNested()
@IsObject()
Upload!: TusdPreCreateUploadDto;
@Type(() => TusdHttpRequestDto)
@ValidateNested()
@IsObject()
HTTPRequest!: TusdHttpRequestDto;
}
export class TusdPreFinishEventDto {
@Type(() => TusdPreFinishUploadDto)
@ValidateNested()
@IsObject()
Upload!: TusdPreFinishUploadDto;
@Type(() => TusdHttpRequestDto)
@ValidateNested()
@IsObject()
HTTPRequest!: TusdHttpRequestDto;
}
export class TusdHookRequestDto {
@IsEnum(TusdHookRequestType)
Type!: TusdHookRequestType;
@IsObject()
Event!: TusdPreCreateEventDto | TusdPreFinishEventDto;
}
export class TusdHttpResponseDto {
StatusCode!: number;
Body?: string;
Header?: Record<string, string>;
}
export class TusdChangeFileInfoStorageDto {
Path?: string;
}
export class TusdChangeFileInfoDto {
ID?: string;
MetaData?: TusdMetaDataDto;
Storage?: TusdChangeFileInfoStorageDto;
}
export class TusdHookResponseDto {
HTTPResponse?: TusdHttpResponseDto;
RejectUpload?: boolean;
ChangeFileInfo?: TusdChangeFileInfoDto;
}

View File

@@ -20,7 +20,6 @@ export enum ImmichHeader {
SharedLinkSlug = 'x-immich-share-slug',
Checksum = 'x-immich-checksum',
Cid = 'x-immich-cid',
AssetData = 'x-immich-asset-data',
}
export enum ImmichQuery {
@@ -494,7 +493,6 @@ export enum BootstrapEventPriority {
JobService = -190,
// Initialise config after other bootstrap services, stop other services from using config on bootstrap
SystemConfig = 100,
UploadService = 200,
}
export enum QueueName {

View File

@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import archiver from 'archiver';
import chokidar, { ChokidarOptions } from 'chokidar';
import { escapePath, glob, globStream } from 'fast-glob';
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, unlinkSync } from 'node:fs';
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { Readable, Writable } from 'node:stream';
@@ -134,16 +134,6 @@ export class StorageRepository {
}
}
unlinkSync(file: string) {
try {
unlinkSync(file);
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
throw error;
}
}
}
async unlinkDir(folder: string, options: { recursive?: boolean; force?: boolean }) {
await fs.rm(folder, options);
}
@@ -166,13 +156,10 @@ export class StorageRepository {
}
}
mkdir(filepath: string): Promise<String | undefined> {
return fs.mkdir(filepath, { recursive: true });
}
mkdirSync(filepath: string): void {
// does not throw an error if the folder already exists
mkdirSync(filepath, { recursive: true });
if (!existsSync(filepath)) {
mkdirSync(filepath, { recursive: true });
}
}
existsSync(filepath: string) {

View File

@@ -1,318 +0,0 @@
import { BadRequestException, Injectable, InternalServerErrorException } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { Request, Response } from 'express';
import { ChildProcess, spawn } from 'node:child_process';
import { request as httpRequest } from 'node:http';
import { createConnection } from 'node:net';
import { extname, join, parse, resolve } from 'node:path';
import {
UPLOAD_CHUNK_DIRECTORY,
UPLOAD_TUSD_CONNECT_BACKOFF_MS,
UPLOAD_TUSD_CONNECT_RETRIES,
UPLOAD_TUSD_CONNECT_TIMEOUT_MS,
UPLOAD_TUSD_SOCKET_PATH,
} from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { OnEvent } from 'src/decorators';
import { AssetMediaStatus } from 'src/dtos/asset-media-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
TusdHookResponseDto,
TusdPreCreateEventDto,
TusdPreFinishEventDto,
UploadAssetDataDto,
} from 'src/dtos/upload.dto';
import { AssetVisibility, ImmichHeader, ImmichWorker, JobName, StorageFolder } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { isAssetChecksumConstraint } from 'src/utils/database';
import { mimeTypes } from 'src/utils/mime-types';
import { fromChecksum } from 'src/utils/request';
@Injectable()
export class AssetUploadService extends BaseService {
private tusdProcess?: ChildProcess;
@OnEvent({ name: 'AppBootstrap', workers: [ImmichWorker.Api] })
async onBootstrap() {
await this.storageRepository.unlinkDir(UPLOAD_CHUNK_DIRECTORY, { recursive: true, force: true });
await this.storageRepository.mkdir(UPLOAD_CHUNK_DIRECTORY);
const { host, port } = this.configRepository.getEnv();
const args = [
'-unix-sock',
UPLOAD_TUSD_SOCKET_PATH,
'--base-path',
'/api/upload/asset',
'--upload-dir',
UPLOAD_CHUNK_DIRECTORY,
'--hooks-http',
`http://${host ?? 'localhost'}:${port}:/api/upload/hook`,
'--hooks-enabled-events',
'pre-create,pre-finish',
'--behind-proxy',
'-enable-experimental-protocol',
'-shutdown-timeout',
'5s',
'-hooks-http-forward-headers',
Object.values(ImmichHeader).join(',') + ',authorization',
'-disable-download',
'-disable-termination',
];
this.logger.log(`Starting tusd server with args: ${args.join(' ')}`);
this.tusdProcess = spawn('tusd', args, {
stdio: ['ignore', 'pipe', 'pipe'],
});
this.tusdProcess.stdout?.on('data', (data) => this.logger.verboseFn(() => `tusd: ${data.toString().trim()}`));
this.tusdProcess.stderr?.on('data', (data) => this.logger.warn(`tusd: ${data.toString().trim()}`));
this.tusdProcess.on('error', (error) => {
this.logger.error(`tusd failed to start: ${error.message}`);
process.exit(1);
});
this.tusdProcess.on('exit', async (code) => {
this.tusdProcess = undefined;
try {
await Promise.all([
this.storageRepository.unlink(UPLOAD_TUSD_SOCKET_PATH),
this.storageRepository.unlinkDir(UPLOAD_CHUNK_DIRECTORY, { recursive: true, force: true }),
]);
} finally {
if (code) {
this.logger.error(`tusd exited unexpectedly with code ${code}`);
// TODO: more graceful shutdown
process.exit(code);
}
}
});
this.logger.log('Waiting for tusd server...');
await this.waitForTusd(UPLOAD_TUSD_SOCKET_PATH);
this.logger.log('tusd server started successfully');
}
@OnEvent({ name: 'AppShutdown' })
onShutdown() {
this.tusdProcess?.kill('SIGTERM');
this.tusdProcess = undefined;
}
async proxyChunks(request: Request, response: Response): Promise<unknown> {
if (!this.tusdProcess) {
throw new InternalServerErrorException('tusd server is not running');
}
delete request.headers.host;
request.headers.connection = 'close'; // not sure why, but it doesn't respond for 60 seconds without this
return new Promise((resolve, reject) => {
const proxyReq = httpRequest(
{
socketPath: UPLOAD_TUSD_SOCKET_PATH,
path: request.url,
method: request.method,
headers: request.headers,
},
(proxyRes) => {
response.status(proxyRes.statusCode || 200);
for (const [key, value] of Object.entries(proxyRes.headers)) {
if (value !== undefined) {
response.setHeader(key, value);
}
}
proxyRes.pipe(response);
proxyRes.on('end', resolve);
proxyRes.on('error', reject);
},
);
proxyReq.on('error', (error: any) => {
this.logger.error(`Failed to proxy to tusd: ${error.message}`);
reject(new InternalServerErrorException('Upload service unavailable'));
});
if (request.readable) {
request.pipe(proxyReq);
} else {
proxyReq.end();
}
});
}
private async waitForTusd(socketPath: string, maxRetries = UPLOAD_TUSD_CONNECT_RETRIES): Promise<void> {
for (let i = 0; i < maxRetries; i++) {
const ready = await new Promise<boolean>((resolve) => {
const socket = createConnection(socketPath, () => {
socket.end();
resolve(true);
});
socket.on('error', () => {
resolve(false);
});
socket.setTimeout(UPLOAD_TUSD_CONNECT_TIMEOUT_MS);
socket.on('timeout', () => {
socket.destroy();
resolve(false);
});
});
if (ready) {
return;
}
await new Promise((r) => setTimeout(r, UPLOAD_TUSD_CONNECT_BACKOFF_MS));
}
throw new Error('tusd server failed to start within timeout');
}
async handlePreCreate(
auth: AuthDto,
payload: TusdPreCreateEventDto,
headers: Record<string, string>,
): Promise<TusdHookResponseDto> {
this.logger.debugFn(() => `PreCreate hook received: ${JSON.stringify(payload)}`);
const checksum = headers[ImmichHeader.Checksum]?.[0];
if (checksum) {
const existingId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, fromChecksum(checksum));
if (existingId) {
const body = JSON.stringify({ status: AssetMediaStatus.DUPLICATE, id: existingId });
return {
RejectUpload: true,
HTTPResponse: { StatusCode: 200, Body: body },
};
}
}
this.requireQuota(auth, payload.Upload.Size);
const encodedAssetData = headers[ImmichHeader.AssetData];
if (!encodedAssetData) {
throw new BadRequestException(`Missing ${ImmichHeader.AssetData} header`);
}
const assetData = this.parseMetadata(encodedAssetData);
this.logger.log('assetData: ' + JSON.stringify(assetData));
const assetId = this.cryptoRepository.randomUUID();
const folder = StorageCore.getNestedFolder(StorageFolder.Upload, auth.user.id, assetId);
const extension = extname(assetId);
const path = join(folder, `${assetId}${extension}`);
return {
ChangeFileInfo: {
ID: assetId,
Storage: { Path: path },
MetaData: {
AssetData: encodedAssetData,
},
},
};
}
async handlePreFinish(auth: AuthDto, dto: TusdPreFinishEventDto): Promise<TusdHookResponseDto> {
this.logger.debugFn(() => `PreFinish hook received: ${JSON.stringify(dto)}`);
const {
Upload: {
MetaData: { AssetData: encodedAssetData },
Storage: { Path: path },
Size: size,
ID: assetId,
},
} = dto;
const parsedPath = parse(resolve(path));
if (!parsedPath.dir.startsWith(StorageCore.getFolderLocation(StorageFolder.Upload, auth.user.id))) {
throw new BadRequestException('Path is not in user folder');
}
const metadata = this.parseMetadata(encodedAssetData);
try {
this.requireQuota(auth, size);
const checksum = await this.cryptoRepository.hashFile(path);
await this.storageRepository.utimes(path, new Date(), new Date(metadata.fileModifiedAt));
try {
await this.assetRepository.create({
id: assetId,
ownerId: auth.user.id,
libraryId: null,
checksum,
originalPath: path,
deviceAssetId: metadata.deviceAssetId,
deviceId: metadata.deviceId,
fileCreatedAt: metadata.fileCreatedAt,
fileModifiedAt: metadata.fileModifiedAt,
localDateTime: metadata.fileCreatedAt,
type: mimeTypes.assetType(path),
isFavorite: metadata.isFavorite,
duration: metadata.duration || null,
visibility: metadata.visibility || AssetVisibility.Timeline,
originalFileName: metadata.filename || parsedPath.base,
});
} catch (error: any) {
if (isAssetChecksumConstraint(error)) {
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, checksum);
if (!duplicateId) {
this.logger.error(`Error locating duplicate for checksum constraint`);
throw new InternalServerErrorException();
}
void this.tryUnlink(path);
const body = JSON.stringify({ id: duplicateId, status: AssetMediaStatus.DUPLICATE });
return { HTTPResponse: { StatusCode: 200, Body: body } };
}
}
} catch (error: any) {
void this.tryUnlink(path);
throw error;
}
await this.userRepository.updateUsage(auth.user.id, size);
await this.assetRepository.upsertExif({ assetId: assetId, fileSizeInByte: size });
await this.jobRepository.queue({
name: JobName.AssetExtractMetadata,
data: { id: assetId, source: 'upload' },
});
this.logger.log(`Asset created from chunked upload: ${assetId}`);
const body = JSON.stringify({ id: assetId, status: AssetMediaStatus.CREATED });
return { HTTPResponse: { StatusCode: 201, Body: body } };
}
private async tryUnlink(path: string): Promise<void> {
try {
await this.storageRepository.unlink(path);
} catch {
this.logger.warn(`Failed to remove file at ${path}`);
}
}
private requireQuota(auth: AuthDto, size: number) {
if (auth.user.quotaSizeInBytes === null) {
return;
}
if (auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) {
throw new BadRequestException('Quota has been exceeded!');
}
}
private parseMetadata(encodedAssetData: string): UploadAssetDataDto {
let assetData: any;
try {
assetData = JSON.parse(Buffer.from(encodedAssetData, 'base64').toString('utf8'));
} catch {
throw new BadRequestException(`Invalid ${ImmichHeader.AssetData} header`);
}
const dto = plainToInstance(UploadAssetDataDto, assetData);
const assetDataErrors = validateSync(dto, { whitelist: true });
if (assetDataErrors.length > 0 || !mimeTypes.isAsset(assetData.filename)) {
throw new BadRequestException(`Invalid ${ImmichHeader.AssetData} header`);
}
return dto;
}
}

View File

@@ -3,7 +3,6 @@ import { AlbumService } from 'src/services/album.service';
import { ApiKeyService } from 'src/services/api-key.service';
import { ApiService } from 'src/services/api.service';
import { AssetMediaService } from 'src/services/asset-media.service';
import { AssetUploadService } from 'src/services/asset-upload.service';
import { AssetService } from 'src/services/asset.service';
import { AuditService } from 'src/services/audit.service';
import { AuthAdminService } from 'src/services/auth-admin.service';
@@ -48,7 +47,6 @@ export const services = [
AlbumService,
ApiService,
AssetMediaService,
AssetUploadService,
AssetService,
AuditService,
AuthService,