Compare commits

..

1 Commits

Author SHA1 Message Date
mertalev
b7e4e55489 wip 2025-09-25 14:17:11 -04:00
76 changed files with 967 additions and 663 deletions

View File

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

View File

@@ -16,7 +16,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
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@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
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@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
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@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
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@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
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@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
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@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
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:41eacbe83eca995561fe43814fd4891e16e39632806253848efaf04d3c8a8b84
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:c44be5f2871c59362966d71eab4268170eb6f5653c0e6170184e72b38ffdf107
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:41eacbe83eca995561fe43814fd4891e16e39632806253848efaf04d3c8a8b84
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:c44be5f2871c59362966d71eab4268170eb6f5653c0e6170184e72b38ffdf107
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:41eacbe83eca995561fe43814fd4891e16e39632806253848efaf04d3c8a8b84
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:c44be5f2871c59362966d71eab4268170eb6f5653c0e6170184e72b38ffdf107
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}

View File

@@ -28,7 +28,6 @@
"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,6 +3,7 @@ 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() {
@@ -16,5 +17,6 @@ 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

@@ -1,16 +0,0 @@
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,50 +2,49 @@ 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(callback: (Result<Unit>) -> Unit) = dispatch(callback = callback) {
BackgroundWorkerPreferences(ctx).setLocked(true)
checkAndEnforceBackgroundLock(ctx)
Log.i(TAG, "Background worker is locked")
}
override fun lock() {
BackgroundWorkerPreferences(ctx).setLocked(true)
checkAndEnforceBackgroundLock(ctx)
Log.i(TAG, "Background worker is locked")
}
override fun unlock(callback: (Result<Unit>) -> Unit) = dispatch(callback = callback) {
BackgroundWorkerPreferences(ctx).setLocked(false)
Log.i(TAG, "Background worker is unlocked")
}
override fun unlock() {
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,12 +133,11 @@ private open class BackgroundWorkerPigeonCodec : StandardMessageCodec() {
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface BackgroundWorkerFgHostApi {
fun enable(callback: (Result<Unit>) -> Unit)
fun configure(settings: BackgroundWorkerSettings, callback: (Result<Unit>) -> Unit)
fun disable(callback: (Result<Unit>) -> Unit)
fun enable()
fun configure(settings: BackgroundWorkerSettings)
fun disable()
companion object {
/** The codec used by BackgroundWorkerFgHostApi. */
@@ -153,14 +152,13 @@ interface BackgroundWorkerFgHostApi {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.enable{ result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(BackgroundWorkerPigeonUtils.wrapError(error))
} else {
reply.reply(BackgroundWorkerPigeonUtils.wrapResult(null))
}
val wrapped: List<Any?> = try {
api.enable()
listOf(null)
} catch (exception: Throwable) {
BackgroundWorkerPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
@@ -172,14 +170,13 @@ interface BackgroundWorkerFgHostApi {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val settingsArg = args[0] as BackgroundWorkerSettings
api.configure(settingsArg) { result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(BackgroundWorkerPigeonUtils.wrapError(error))
} else {
reply.reply(BackgroundWorkerPigeonUtils.wrapResult(null))
}
val wrapped: List<Any?> = try {
api.configure(settingsArg)
listOf(null)
} catch (exception: Throwable) {
BackgroundWorkerPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
@@ -189,14 +186,13 @@ interface BackgroundWorkerFgHostApi {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.disable{ result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(BackgroundWorkerPigeonUtils.wrapError(error))
} else {
reply.reply(BackgroundWorkerPigeonUtils.wrapResult(null))
}
val wrapped: List<Any?> = try {
api.disable()
listOf(null)
} catch (exception: Throwable) {
BackgroundWorkerPigeonUtils.wrapError(exception)
}
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 (_: Exception) {
} catch (e: Exception) {
// ignored, there is nothing to be done
}
}

View File

@@ -8,7 +8,6 @@ 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
@@ -17,18 +16,16 @@ private const val TAG = "BackgroundWorkerApiImpl"
class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
private val ctx: Context = context.applicationContext
override fun enable(callback: (Result<Unit>) -> Unit) =
dispatch(callback = callback) { enqueueMediaObserver(ctx) }
override fun enable() {
enqueueMediaObserver(ctx)
}
override fun configure(
settings: BackgroundWorkerSettings,
callback: (Result<Unit>) -> Unit
) = dispatch(callback = callback) {
override fun configure(settings: BackgroundWorkerSettings) {
BackgroundWorkerPreferences(ctx).updateSettings(settings)
enqueueMediaObserver(ctx)
}
override fun disable(callback: (Result<Unit>) -> Unit) = dispatch(callback = callback) {
override fun disable() {
WorkManager.getInstance(ctx).apply {
cancelUniqueWork(OBSERVER_WORKER_NAME)
cancelUniqueWork(BACKGROUND_WORKER_NAME)
@@ -41,6 +38,7 @@ 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,11 +44,10 @@ private open class BackgroundWorkerLockPigeonCodec : StandardMessageCodec() {
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface BackgroundWorkerLockApi {
fun lock(callback: (Result<Unit>) -> Unit)
fun unlock(callback: (Result<Unit>) -> Unit)
fun lock()
fun unlock()
companion object {
/** The codec used by BackgroundWorkerLockApi. */
@@ -63,14 +62,13 @@ interface BackgroundWorkerLockApi {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerLockApi.lock$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.lock{ result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(BackgroundWorkerLockPigeonUtils.wrapError(error))
} else {
reply.reply(BackgroundWorkerLockPigeonUtils.wrapResult(null))
}
val wrapped: List<Any?> = try {
api.lock()
listOf(null)
} catch (exception: Throwable) {
BackgroundWorkerLockPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
@@ -80,14 +78,13 @@ interface BackgroundWorkerLockApi {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerLockApi.unlock$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.unlock{ result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(BackgroundWorkerLockPigeonUtils.wrapError(error))
} else {
reply.reply(BackgroundWorkerLockPigeonUtils.wrapResult(null))
}
val wrapped: List<Any?> = try {
api.unlock()
listOf(null)
} catch (exception: Throwable) {
BackgroundWorkerLockPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)

View File

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

View File

@@ -4,7 +4,6 @@ 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 =
@@ -12,13 +11,7 @@ class ConnectivityApiImpl(context: Context) : ConnectivityApi {
private val wifiManager =
context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
override fun getCapabilities(callback: (Result<List<NetworkCapability>>) -> Unit) =
dispatch(callback = callback) {
getCapabilities()
}
private fun getCapabilities(): List<NetworkCapability> {
override 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(callback: (Result<SyncDelta>) -> Unit)
fun getMediaChanges(): SyncDelta
fun checkpointSync()
fun clearSyncCheckpoint()
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 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 hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
fun cancelHashing()
@@ -335,15 +335,12 @@ interface NativeSyncApi {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { _, reply ->
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))
}
val wrapped: List<Any?> = try {
listOf(api.getMediaChanges())
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
@@ -387,15 +384,12 @@ interface NativeSyncApi {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val albumIdArg = args[0] as String
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))
}
val wrapped: List<Any?> = try {
listOf(api.getAssetIdsForAlbum(albumIdArg))
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
@@ -405,15 +399,12 @@ interface NativeSyncApi {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { _, reply ->
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))
}
val wrapped: List<Any?> = try {
listOf(api.getAlbums())
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
@@ -426,15 +417,12 @@ interface NativeSyncApi {
val args = message as List<Any?>
val albumIdArg = args[0] as String
val timestampArg = args[1] as Long
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))
}
val wrapped: List<Any?> = try {
listOf(api.getAssetsCountSince(albumIdArg, timestampArg))
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
@@ -447,15 +435,12 @@ interface NativeSyncApi {
val args = message as List<Any?>
val albumIdArg = args[0] as String
val updatedTimeCondArg = args[1] as Long?
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))
}
val wrapped: List<Any?> = try {
listOf(api.getAssetsForAlbum(albumIdArg, updatedTimeCondArg))
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
}
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(callback: (Result<SyncDelta>) -> Unit) =
callback(Result.failure(IllegalStateException("Method not supported on this Android version.")))
override fun getMediaChanges(): SyncDelta {
throw IllegalStateException("Method not supported on this Android version.")
}
}

View File

@@ -5,7 +5,6 @@ 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)
@@ -48,12 +47,7 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
}
}
override fun getMediaChanges(callback: (Result<SyncDelta>) -> Unit) =
dispatch(callback = callback) {
getMediaChanges()
}
private fun getMediaChanges(): SyncDelta {
override fun getMediaChanges(): SyncDelta {
val genMap = getSavedGenerationMap()
val currentVolumes = MediaStore.getExternalVolumeNames(ctx)
val changed = mutableListOf<PlatformAsset>()

View File

@@ -7,7 +7,6 @@ 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
@@ -146,10 +145,7 @@ open class NativeSyncApiImplBase(context: Context) {
}
}
fun getAlbums(callback: (Result<List<PlatformAlbum>>) -> Unit) =
dispatch(callback = callback) { getAlbums() }
private fun getAlbums(): List<PlatformAlbum> {
fun getAlbums(): List<PlatformAlbum> {
val albums = mutableListOf<PlatformAlbum>()
val albumsCount = mutableMapOf<String, Int>()
@@ -196,10 +192,7 @@ open class NativeSyncApiImplBase(context: Context) {
.sortedBy { it.id }
}
fun getAssetIdsForAlbum(albumId: String, callback: (Result<List<String>>) -> Unit) =
dispatch(callback = callback) { getAssetIdsForAlbum(albumId); }
private fun getAssetIdsForAlbum(albumId: String): List<String> {
fun getAssetIdsForAlbum(albumId: String): List<String> {
val projection = arrayOf(MediaStore.MediaColumns._ID)
return getCursor(
@@ -215,23 +208,15 @@ open class NativeSyncApiImplBase(context: Context) {
} ?: emptyList()
}
fun getAssetsCountSince(albumId: String, timestamp: Long, callback: (Result<Long>) -> Unit) =
dispatch(callback = callback) { getAssetsCountSince(albumId, timestamp) }
private fun getAssetsCountSince(albumId: String, timestamp: Long): Long =
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) }
private fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset> {
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset> {
var selection = "$BUCKET_SELECTION AND $MEDIA_SELECTION"
val selectionArgs = mutableListOf(albumId, *MEDIA_SELECTION_ARGS)
@@ -269,7 +254,7 @@ open class NativeSyncApiImplBase(context: Context) {
}.awaitAll()
callback(Result.success(results))
} catch (_: CancellationException) {
} catch (e: CancellationException) {
callback(
Result.failure(
FlutterError(

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
@@ -133,14 +133,11 @@
/* 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 = (
@@ -250,7 +247,6 @@
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
B2D27ABE2E84A0FF004DD55B /* Core */,
B25D37792E72CA15008B6CA7 /* Connectivity */,
B21E34A62E5AF9760031FDB9 /* Background */,
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
@@ -336,7 +332,6 @@
);
fileSystemSynchronizedGroups = (
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
B2D27ABE2E84A0FF004DD55B /* Core */,
);
name = Runner;
productName = Runner;
@@ -526,14 +521,10 @@
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";
@@ -562,14 +553,10 @@
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,12 +179,11 @@ 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(completion: @escaping (Result<Void, Error>) -> Void)
func configure(settings: BackgroundWorkerSettings, completion: @escaping (Result<Void, Error>) -> Void)
func disable(completion: @escaping (Result<Void, Error>) -> Void)
func enable() throws
func configure(settings: BackgroundWorkerSettings) throws
func disable() throws
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -196,13 +195,11 @@ 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
api.enable { result in
switch result {
case .success:
reply(wrapResult(nil))
case .failure(let error):
reply(wrapError(error))
}
do {
try api.enable()
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
@@ -213,13 +210,11 @@ class BackgroundWorkerFgHostApiSetup {
configureChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let settingsArg = args[0] as! BackgroundWorkerSettings
api.configure(settings: settingsArg) { result in
switch result {
case .success:
reply(wrapResult(nil))
case .failure(let error):
reply(wrapError(error))
}
do {
try api.configure(settings: settingsArg)
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
@@ -228,13 +223,11 @@ 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
api.disable { result in
switch result {
case .success:
reply(wrapResult(nil))
case .failure(let error):
reply(wrapError(error))
}
do {
try api.disable()
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {

View File

@@ -1,27 +1,23 @@
import BackgroundTasks
class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
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, completion: @escaping (Result<Void, any Error>) -> Void) {
// Android only
completion(Result.success(Void()))
}
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")
}
}
func enable() throws {
BackgroundWorkerApiImpl.scheduleRefreshWorker()
BackgroundWorkerApiImpl.scheduleProcessingWorker()
print("BackgroundWorkerApiImpl:enable Background worker scheduled")
}
func configure(settings: BackgroundWorkerSettings) throws {
// Android only
}
func disable() throws {
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,10 +94,9 @@ 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(completion: @escaping (Result<[NetworkCapability], Error>) -> Void)
func getCapabilities() throws -> [NetworkCapability]
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -116,13 +115,11 @@ 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
api.getCapabilities { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
do {
let result = try api.getCapabilities()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {

View File

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

View File

@@ -1,9 +0,0 @@
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(completion: @escaping (Result<SyncDelta, Error>) -> Void)
func getMediaChanges() throws -> SyncDelta
func checkpointSync() throws
func clearSyncCheckpoint() throws
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 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 hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
func cancelHashing() throws
}
@@ -395,13 +395,11 @@ 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
api.getMediaChanges { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
do {
let result = try api.getMediaChanges()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
@@ -440,13 +438,11 @@ class NativeSyncApiSetup {
getAssetIdsForAlbumChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let albumIdArg = args[0] as! String
api.getAssetIdsForAlbum(albumId: albumIdArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
do {
let result = try api.getAssetIdsForAlbum(albumId: albumIdArg)
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
@@ -457,13 +453,11 @@ 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
api.getAlbums { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
do {
let result = try api.getAlbums()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
@@ -477,13 +471,11 @@ class NativeSyncApiSetup {
let args = message as! [Any?]
let albumIdArg = args[0] as! String
let timestampArg = args[1] as! Int64
api.getAssetsCountSince(albumId: albumIdArg, timestamp: timestampArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
do {
let result = try api.getAssetsCountSince(albumId: albumIdArg, timestamp: timestampArg)
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
@@ -497,13 +489,11 @@ class NativeSyncApiSetup {
let args = message as! [Any?]
let albumIdArg = args[0] as! String
let updatedTimeCondArg: Int64? = nilOrValue(args[1])
api.getAssetsForAlbum(albumId: albumIdArg, updatedTimeCond: updatedTimeCondArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
do {
let result = try api.getAssetsForAlbum(albumId: albumIdArg, updatedTimeCond: updatedTimeCondArg)
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,10 +25,9 @@ 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,
shadowColor: Colors.black26,
elevation: 4,
backgroundColor: Colors.indigo.withValues(alpha: 0.7),
),
),
),

View File

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

View File

@@ -51,7 +51,6 @@ 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.isRemoteOnly) const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
if (asset.hasRemote) 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,7 +13,6 @@ 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';
@@ -45,7 +44,6 @@ 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,7 +13,6 @@ 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';
@@ -45,7 +44,6 @@ 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,7 +5,6 @@ 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';
@@ -20,7 +19,6 @@ 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';
@@ -64,19 +62,11 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
return;
}
final remoteAssets = selectedAssets.whereType<RemoteAsset>();
final addedCount = await ref
.read(remoteAlbumProvider.notifier)
.addAssets(album.id, remoteAssets.map((e) => e.id).toList());
.addAssets(album.id, selectedAssets.map((e) => (e as RemoteAsset).id).toList());
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) {
if (addedCount != selectedAssets.length) {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {"album": album.name}),
@@ -118,18 +108,15 @@ 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: multiselect.hasRemote
? [
const AddToAlbumHeader(),
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
]
: [],
slivers: [
const AddToAlbumHeader(),
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
],
);
}
}

View File

@@ -1,6 +1,5 @@
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';
@@ -11,14 +10,13 @@ class MapBottomSheet extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BaseBottomSheet(
return const BaseBottomSheet(
initialChildSize: 0.25,
maxChildSize: 0.9,
shouldCloseOnMinExtent: false,
resizeOnScroll: false,
actions: [],
backgroundColor: context.themeData.colorScheme.surface,
slivers: [const SliverFillRemaining(hasScrollBody: false, child: _ScopedMapTimeline())],
slivers: [SliverFillRemaining(hasScrollBody: false, child: _ScopedMapTimeline())],
);
}
}

View File

@@ -17,7 +17,6 @@ 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';
@@ -103,7 +102,6 @@ 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,14 +123,28 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
return provider;
}
ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution}) {
if (_shouldUseLocalAsset(asset)) {
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!)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
return LocalThumbProvider(id: id, size: size, assetType: asset.type);
}
final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId;
return assetId != null ? RemoteThumbProvider(assetId: assetId) : null;
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);
}
bool _shouldUseLocalAsset(BaseAsset asset) =>

View File

@@ -5,6 +5,7 @@ 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';
@@ -38,7 +39,14 @@ class Thumbnail extends StatefulWidget {
),
_ => null,
},
imageProvider = asset == null ? null : getThumbnailImageProvider(asset, size: size);
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,
};
@override
State<Thumbnail> createState() => _ThumbnailState();

View File

@@ -54,6 +54,8 @@ 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)));
@@ -75,10 +77,21 @@ class ThumbnailTile extends ConsumerWidget {
child: Thumbnail.fromAsset(asset: asset, size: size),
),
),
if (asset != null)
if (hasStack)
Align(
alignment: Alignment.topRight,
child: _AssetTypeIcons(asset: asset),
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),
),
),
if (storageIndicator && asset != null)
switch (asset.storage) {
@@ -201,34 +214,3 @@ 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,6 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
@@ -10,8 +9,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.state.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/utils/async_mutex.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -188,8 +187,6 @@ 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,14 +212,11 @@ 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;
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));
_scrollController.animateTo(
targetOffset.clamp(0.0, _scrollController.position.maxScrollExtent),
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
);
}
});
}

View File

@@ -1,5 +1,6 @@
// 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';
@@ -11,8 +12,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:immich_mobile/utils/debug_print.dart';
import 'package:logging/logging.dart';
import 'package:immich_mobile/utils/debug_print.dart';
class EnqueueStatus {
final int enqueueCount;
@@ -89,6 +90,33 @@ 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 {
@@ -239,7 +267,6 @@ 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,10 +3,7 @@ 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';
@@ -39,7 +36,6 @@ class ActionNotifier extends Notifier<void> {
late ActionService _service;
late UploadService _uploadService;
late DownloadService _downloadService;
late AssetService _assetService;
ActionNotifier() : super();
@@ -47,7 +43,6 @@ 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;
@@ -340,14 +335,6 @@ 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,8 +28,6 @@ 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,7 +9,6 @@ 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';
@@ -20,9 +19,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(
@@ -206,20 +205,10 @@ class UploadService {
return _uploadRepository.start();
}
void _handleTaskStatusUpdate(TaskStatusUpdate update) async {
void _handleTaskStatusUpdate(TaskStatusUpdate update) {
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,7 +16,6 @@ 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 {
@@ -25,7 +24,6 @@ class ActionButtonContext {
final bool isArchived;
final bool isTrashEnabled;
final bool isInLockedView;
final bool isStacked;
final RemoteAlbum? currentAlbum;
final bool advancedTroubleshooting;
final ActionSource source;
@@ -35,7 +33,6 @@ class ActionButtonContext {
required this.isOwner,
required this.isArchived,
required this.isTrashEnabled,
required this.isStacked,
required this.isInLockedView,
required this.currentAlbum,
required this.advancedTroubleshooting,
@@ -58,7 +55,6 @@ enum ActionButtonType {
deleteLocal,
upload,
removeFromAlbum,
unstack,
likeActivity;
bool shouldShow(ActionButtonContext context) {
@@ -114,10 +110,6 @@ enum ActionButtonType {
context.isOwner && //
!context.isInLockedView && //
context.currentAlbum != null,
ActionButtonType.unstack =>
context.isOwner && //
!context.isInLockedView && //
context.isStacked,
ActionButtonType.likeActivity =>
!context.isInLockedView &&
context.currentAlbum != null &&
@@ -146,13 +138,28 @@ enum ActionButtonType {
source: context.source,
),
ActionButtonType.likeActivity => const LikeActivityActionButton(),
ActionButtonType.unstack => UnStackActionButton(source: context.source),
};
}
}
class ActionButtonBuilder {
static const List<ActionButtonType> _actionTypes = ActionButtonType.values;
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 List<Widget> build(ActionButtonContext context) {
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();

View File

@@ -129,24 +129,19 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
title: Builder(
builder: (BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
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),
),
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,
),
);
},
),
],
);

View File

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

View File

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

View File

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

View File

@@ -83,7 +83,6 @@ class HashResult {
abstract class NativeSyncApi {
bool shouldFullSync();
@async
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
SyncDelta getMediaChanges();
@@ -91,19 +90,15 @@ 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,7 +82,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -113,7 +112,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -129,7 +127,6 @@ void main() {
isInLockedView: true,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -148,7 +145,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -165,7 +161,6 @@ void main() {
isInLockedView: true,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -182,7 +177,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -201,7 +195,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -218,7 +211,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -235,7 +227,6 @@ void main() {
isInLockedView: true,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -252,7 +243,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -269,7 +259,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -288,7 +277,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -305,7 +293,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -322,7 +309,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -341,7 +327,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -358,7 +343,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -375,7 +359,6 @@ void main() {
isInLockedView: true,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -394,7 +377,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -411,7 +393,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -430,7 +411,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -447,7 +427,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -466,7 +445,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -485,7 +463,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -504,7 +481,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -521,7 +497,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -537,7 +512,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -556,7 +530,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -575,7 +548,6 @@ void main() {
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -591,7 +563,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -610,7 +581,6 @@ void main() {
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -627,7 +597,6 @@ void main() {
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -644,7 +613,6 @@ void main() {
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -660,7 +628,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -678,7 +645,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: true,
isStacked: false,
source: ActionSource.timeline,
);
@@ -694,7 +660,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -703,59 +668,6 @@ 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;
@@ -770,7 +682,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
});
@@ -787,22 +698,6 @@ 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);
@@ -826,7 +721,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -847,7 +741,6 @@ void main() {
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -866,7 +759,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -886,7 +778,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
@@ -900,7 +791,6 @@ void main() {
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);

View File

@@ -1,4 +1,21 @@
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
@@ -40,6 +57,7 @@ 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,6 +1,21 @@
# 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,7 +28,6 @@ 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 },
@@ -85,7 +84,7 @@ class BaseModule implements OnModuleInit, OnModuleDestroy {
@Module({
imports: [...imports, ScheduleModule.forRoot()],
controllers: [...controllers],
providers: [...common, ...middleware, { provide: IWorker, useValue: ImmichWorker.Api }],
providers: [...common, ...middleware, FileUploadInterceptor, { provide: IWorker, useValue: ImmichWorker.Api }],
})
export class ApiModule extends BaseModule {}

View File

@@ -16,6 +16,11 @@ 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

@@ -0,0 +1,82 @@
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,6 +3,7 @@ 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';
@@ -40,6 +41,7 @@ export const controllers = [
AppController,
AssetController,
AssetMediaController,
AssetUploadController,
AuthController,
AuthAdminController,
DownloadController,

View File

@@ -0,0 +1,133 @@
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,6 +20,7 @@ export enum ImmichHeader {
SharedLinkSlug = 'x-immich-share-slug',
Checksum = 'x-immich-checksum',
Cid = 'x-immich-cid',
AssetData = 'x-immich-asset-data',
}
export enum ImmichQuery {
@@ -493,6 +494,7 @@ 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 } from 'node:fs';
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, unlinkSync } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { Readable, Writable } from 'node:stream';
@@ -134,6 +134,16 @@ 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);
}
@@ -156,10 +166,13 @@ export class StorageRepository {
}
}
mkdir(filepath: string): Promise<String | undefined> {
return fs.mkdir(filepath, { recursive: true });
}
mkdirSync(filepath: string): void {
if (!existsSync(filepath)) {
mkdirSync(filepath, { recursive: true });
}
// does not throw an error if the folder already exists
mkdirSync(filepath, { recursive: true });
}
existsSync(filepath: string) {

View File

@@ -0,0 +1,318 @@
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,6 +3,7 @@ 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';
@@ -47,6 +48,7 @@ export const services = [
AlbumService,
ApiService,
AssetMediaService,
AssetUploadService,
AssetService,
AuditService,
AuthService,