Compare commits
19 Commits
feat/serve
...
fix/async-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f632e4f666 | ||
|
|
0e8492ceba | ||
|
|
13abe14142 | ||
|
|
ae595f2947 | ||
|
|
bea116e1b9 | ||
|
|
cdbe1d7f10 | ||
|
|
df469cc412 | ||
|
|
8de7eed940 | ||
|
|
7d8cd05bc2 | ||
|
|
30a378c580 | ||
|
|
8a3684c127 | ||
|
|
61e5c6349c | ||
|
|
3bcb4b7af7 | ||
|
|
5116b215a2 | ||
|
|
c5fbbee8f6 | ||
|
|
d73aabc494 | ||
|
|
b62feb726b | ||
|
|
972e9cc039 | ||
|
|
ee49136e97 |
6
.github/workflows/build-mobile.yml
vendored
6
.github/workflows/build-mobile.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check what should run
|
- name: Check what should run
|
||||||
id: check
|
id: check
|
||||||
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
|
uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0
|
||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
mobile:
|
mobile:
|
||||||
@@ -73,7 +73,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Restore Gradle Cache
|
- name: Restore Gradle Cache
|
||||||
id: cache-gradle-restore
|
id: cache-gradle-restore
|
||||||
uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4
|
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
@@ -130,7 +130,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Save Gradle Cache
|
- name: Save Gradle Cache
|
||||||
id: cache-gradle-save
|
id: cache-gradle-save
|
||||||
uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4
|
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||||
if: github.ref == 'refs/heads/main'
|
if: github.ref == 'refs/heads/main'
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
|
|||||||
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check what should run
|
- name: Check what should run
|
||||||
id: check
|
id: check
|
||||||
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
|
uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0
|
||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
server:
|
server:
|
||||||
|
|||||||
2
.github/workflows/docs-build.yml
vendored
2
.github/workflows/docs-build.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check what should run
|
- name: Check what should run
|
||||||
id: check
|
id: check
|
||||||
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
|
uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0
|
||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
docs:
|
docs:
|
||||||
|
|||||||
2
.github/workflows/fix-format.yml
vendored
2
.github/workflows/fix-format.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Generate a token
|
- name: Generate a token
|
||||||
id: generate-token
|
id: generate-token
|
||||||
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
|
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|||||||
2
.github/workflows/merge-translations.yml
vendored
2
.github/workflows/merge-translations.yml
vendored
@@ -58,7 +58,7 @@ jobs:
|
|||||||
- name: Generate a token
|
- name: Generate a token
|
||||||
id: generate_token
|
id: generate_token
|
||||||
if: ${{ inputs.skip != true }}
|
if: ${{ inputs.skip != true }}
|
||||||
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
|
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|||||||
4
.github/workflows/prepare-release.yml
vendored
4
.github/workflows/prepare-release.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Generate a token
|
- name: Generate a token
|
||||||
id: generate-token
|
id: generate-token
|
||||||
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
|
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
@@ -111,7 +111,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Generate a token
|
- name: Generate a token
|
||||||
id: generate-token
|
id: generate-token
|
||||||
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
|
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|||||||
2
.github/workflows/static_analysis.yml
vendored
2
.github/workflows/static_analysis.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check what should run
|
- name: Check what should run
|
||||||
id: check
|
id: check
|
||||||
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
|
uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0
|
||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
mobile:
|
mobile:
|
||||||
|
|||||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check what should run
|
- name: Check what should run
|
||||||
id: check
|
id: check
|
||||||
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
|
uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0
|
||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
i18n:
|
i18n:
|
||||||
|
|||||||
2
.github/workflows/weblate-lock.yml
vendored
2
.github/workflows/weblate-lock.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check what should run
|
- name: Check what should run
|
||||||
id: check
|
id: check
|
||||||
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
|
uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0
|
||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
i18n:
|
i18n:
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ services:
|
|||||||
|
|
||||||
database:
|
database:
|
||||||
container_name: immich_postgres
|
container_name: immich_postgres
|
||||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:c44be5f2871c59362966d71eab4268170eb6f5653c0e6170184e72b38ffdf107
|
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:41eacbe83eca995561fe43814fd4891e16e39632806253848efaf04d3c8a8b84
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ services:
|
|||||||
|
|
||||||
database:
|
database:
|
||||||
container_name: immich_postgres
|
container_name: immich_postgres
|
||||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:c44be5f2871c59362966d71eab4268170eb6f5653c0e6170184e72b38ffdf107
|
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:41eacbe83eca995561fe43814fd4891e16e39632806253848efaf04d3c8a8b84
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ services:
|
|||||||
|
|
||||||
database:
|
database:
|
||||||
container_name: immich_postgres
|
container_name: immich_postgres
|
||||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:c44be5f2871c59362966d71eab4268170eb6f5653c0e6170184e72b38ffdf107
|
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:41eacbe83eca995561fe43814fd4891e16e39632806253848efaf04d3c8a8b84
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
POSTGRES_USER: ${DB_USERNAME}
|
POSTGRES_USER: ${DB_USERNAME}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"add_to_album": "Add to album",
|
"add_to_album": "Add to album",
|
||||||
"add_to_album_bottom_sheet_added": "Added 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_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_album_toggle": "Toggle selection for {album}",
|
||||||
"add_to_albums": "Add to albums",
|
"add_to_albums": "Add to albums",
|
||||||
"add_to_albums_count": "Add to albums ({count})",
|
"add_to_albums_count": "Add to albums ({count})",
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package app.alextran.immich
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.work.Configuration
|
import androidx.work.Configuration
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import app.alextran.immich.background.BackgroundWorkerApiImpl
|
|
||||||
|
|
||||||
class ImmichApp : Application() {
|
class ImmichApp : Application() {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
@@ -17,6 +16,5 @@ class ImmichApp : Application() {
|
|||||||
// As a workaround, we also run a backup check when initializing the application
|
// As a workaround, we also run a backup check when initializing the application
|
||||||
|
|
||||||
ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0)
|
ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0)
|
||||||
BackgroundWorkerApiImpl.enqueueBackgroundWorker(this)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package app.alextran.immich
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
fun <T> dispatch(
|
||||||
|
dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
|
callback: (Result<T>) -> Unit,
|
||||||
|
block: () -> T
|
||||||
|
) {
|
||||||
|
CoroutineScope(dispatcher).launch {
|
||||||
|
callback(runCatching { block() })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,49 +2,50 @@ package app.alextran.immich.background
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import app.alextran.immich.dispatch
|
||||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
private const val TAG = "BackgroundEngineLock"
|
private const val TAG = "BackgroundEngineLock"
|
||||||
|
|
||||||
class BackgroundEngineLock(context: Context) : BackgroundWorkerLockApi, FlutterPlugin {
|
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) {
|
private fun checkAndEnforceBackgroundLock(ctx: Context) {
|
||||||
// work manager task is running while the main app is opened, cancel the worker
|
// work manager task is running while the main app is opened, cancel the worker
|
||||||
if (BackgroundWorkerPreferences(ctx).isLocked() &&
|
if (BackgroundWorkerPreferences(ctx).isLocked() &&
|
||||||
engineCount.get() > 1 &&
|
engineCount.get() > 1 &&
|
||||||
BackgroundWorkerApiImpl.isBackgroundWorkerRunning()
|
BackgroundWorkerApiImpl.isBackgroundWorkerRunning()
|
||||||
) {
|
) {
|
||||||
Log.i(TAG, "Background worker is locked, cancelling the background worker")
|
Log.i(TAG, "Background worker is locked, cancelling the background worker")
|
||||||
BackgroundWorkerApiImpl.cancelBackgroundWorker(ctx)
|
BackgroundWorkerApiImpl.cancelBackgroundWorker(ctx)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun lock() {
|
override fun lock(callback: (Result<Unit>) -> Unit) = dispatch(callback = callback) {
|
||||||
BackgroundWorkerPreferences(ctx).setLocked(true)
|
BackgroundWorkerPreferences(ctx).setLocked(true)
|
||||||
checkAndEnforceBackgroundLock(ctx)
|
checkAndEnforceBackgroundLock(ctx)
|
||||||
Log.i(TAG, "Background worker is locked")
|
Log.i(TAG, "Background worker is locked")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun unlock() {
|
override fun unlock(callback: (Result<Unit>) -> Unit) = dispatch(callback = callback) {
|
||||||
BackgroundWorkerPreferences(ctx).setLocked(false)
|
BackgroundWorkerPreferences(ctx).setLocked(false)
|
||||||
Log.i(TAG, "Background worker is unlocked")
|
Log.i(TAG, "Background worker is unlocked")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
checkAndEnforceBackgroundLock(binding.applicationContext)
|
checkAndEnforceBackgroundLock(binding.applicationContext)
|
||||||
engineCount.incrementAndGet()
|
engineCount.incrementAndGet()
|
||||||
Log.i(TAG, "Flutter engine attached. Attached Engines count: $engineCount")
|
Log.i(TAG, "Flutter engine attached. Attached Engines count: $engineCount")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
engineCount.decrementAndGet()
|
engineCount.decrementAndGet()
|
||||||
Log.i(TAG, "Flutter engine detached. Attached Engines count: $engineCount")
|
Log.i(TAG, "Flutter engine detached. Attached Engines count: $engineCount")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,11 +133,12 @@ private open class BackgroundWorkerPigeonCodec : StandardMessageCodec() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
interface BackgroundWorkerFgHostApi {
|
interface BackgroundWorkerFgHostApi {
|
||||||
fun enable()
|
fun enable(callback: (Result<Unit>) -> Unit)
|
||||||
fun configure(settings: BackgroundWorkerSettings)
|
fun configure(settings: BackgroundWorkerSettings, callback: (Result<Unit>) -> Unit)
|
||||||
fun disable()
|
fun disable(callback: (Result<Unit>) -> Unit)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The codec used by BackgroundWorkerFgHostApi. */
|
/** The codec used by BackgroundWorkerFgHostApi. */
|
||||||
@@ -152,13 +153,14 @@ interface BackgroundWorkerFgHostApi {
|
|||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable$separatedMessageChannelSuffix", codec)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable$separatedMessageChannelSuffix", codec)
|
||||||
if (api != null) {
|
if (api != null) {
|
||||||
channel.setMessageHandler { _, reply ->
|
channel.setMessageHandler { _, reply ->
|
||||||
val wrapped: List<Any?> = try {
|
api.enable{ result: Result<Unit> ->
|
||||||
api.enable()
|
val error = result.exceptionOrNull()
|
||||||
listOf(null)
|
if (error != null) {
|
||||||
} catch (exception: Throwable) {
|
reply.reply(BackgroundWorkerPigeonUtils.wrapError(error))
|
||||||
BackgroundWorkerPigeonUtils.wrapError(exception)
|
} else {
|
||||||
|
reply.reply(BackgroundWorkerPigeonUtils.wrapResult(null))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
reply.reply(wrapped)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
@@ -170,13 +172,14 @@ interface BackgroundWorkerFgHostApi {
|
|||||||
channel.setMessageHandler { message, reply ->
|
channel.setMessageHandler { message, reply ->
|
||||||
val args = message as List<Any?>
|
val args = message as List<Any?>
|
||||||
val settingsArg = args[0] as BackgroundWorkerSettings
|
val settingsArg = args[0] as BackgroundWorkerSettings
|
||||||
val wrapped: List<Any?> = try {
|
api.configure(settingsArg) { result: Result<Unit> ->
|
||||||
api.configure(settingsArg)
|
val error = result.exceptionOrNull()
|
||||||
listOf(null)
|
if (error != null) {
|
||||||
} catch (exception: Throwable) {
|
reply.reply(BackgroundWorkerPigeonUtils.wrapError(error))
|
||||||
BackgroundWorkerPigeonUtils.wrapError(exception)
|
} else {
|
||||||
|
reply.reply(BackgroundWorkerPigeonUtils.wrapResult(null))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
reply.reply(wrapped)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
@@ -186,13 +189,14 @@ interface BackgroundWorkerFgHostApi {
|
|||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$separatedMessageChannelSuffix", codec)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$separatedMessageChannelSuffix", codec)
|
||||||
if (api != null) {
|
if (api != null) {
|
||||||
channel.setMessageHandler { _, reply ->
|
channel.setMessageHandler { _, reply ->
|
||||||
val wrapped: List<Any?> = try {
|
api.disable{ result: Result<Unit> ->
|
||||||
api.disable()
|
val error = result.exceptionOrNull()
|
||||||
listOf(null)
|
if (error != null) {
|
||||||
} catch (exception: Throwable) {
|
reply.reply(BackgroundWorkerPigeonUtils.wrapError(error))
|
||||||
BackgroundWorkerPigeonUtils.wrapError(exception)
|
} else {
|
||||||
|
reply.reply(BackgroundWorkerPigeonUtils.wrapResult(null))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
reply.reply(wrapped)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
|||||||
if (foregroundFuture != null && !foregroundFuture.isCancelled && !foregroundFuture.isDone) {
|
if (foregroundFuture != null && !foregroundFuture.isCancelled && !foregroundFuture.isDone) {
|
||||||
try {
|
try {
|
||||||
foregroundFuture.get(500, TimeUnit.MILLISECONDS)
|
foregroundFuture.get(500, TimeUnit.MILLISECONDS)
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
// ignored, there is nothing to be done
|
// ignored, there is nothing to be done
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.work.Constraints
|
|||||||
import androidx.work.ExistingWorkPolicy
|
import androidx.work.ExistingWorkPolicy
|
||||||
import androidx.work.OneTimeWorkRequest
|
import androidx.work.OneTimeWorkRequest
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
|
import app.alextran.immich.dispatch
|
||||||
import io.flutter.embedding.engine.FlutterEngineCache
|
import io.flutter.embedding.engine.FlutterEngineCache
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@@ -16,16 +17,18 @@ private const val TAG = "BackgroundWorkerApiImpl"
|
|||||||
class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
||||||
private val ctx: Context = context.applicationContext
|
private val ctx: Context = context.applicationContext
|
||||||
|
|
||||||
override fun enable() {
|
override fun enable(callback: (Result<Unit>) -> Unit) =
|
||||||
enqueueMediaObserver(ctx)
|
dispatch(callback = callback) { enqueueMediaObserver(ctx) }
|
||||||
}
|
|
||||||
|
|
||||||
override fun configure(settings: BackgroundWorkerSettings) {
|
override fun configure(
|
||||||
|
settings: BackgroundWorkerSettings,
|
||||||
|
callback: (Result<Unit>) -> Unit
|
||||||
|
) = dispatch(callback = callback) {
|
||||||
BackgroundWorkerPreferences(ctx).updateSettings(settings)
|
BackgroundWorkerPreferences(ctx).updateSettings(settings)
|
||||||
enqueueMediaObserver(ctx)
|
enqueueMediaObserver(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun disable() {
|
override fun disable(callback: (Result<Unit>) -> Unit) = dispatch(callback = callback) {
|
||||||
WorkManager.getInstance(ctx).apply {
|
WorkManager.getInstance(ctx).apply {
|
||||||
cancelUniqueWork(OBSERVER_WORKER_NAME)
|
cancelUniqueWork(OBSERVER_WORKER_NAME)
|
||||||
cancelUniqueWork(BACKGROUND_WORKER_NAME)
|
cancelUniqueWork(BACKGROUND_WORKER_NAME)
|
||||||
@@ -38,7 +41,6 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
|||||||
private const val OBSERVER_WORKER_NAME = "immich/MediaObserverV1"
|
private const val OBSERVER_WORKER_NAME = "immich/MediaObserverV1"
|
||||||
const val ENGINE_CACHE_KEY = "immich::background_worker::engine"
|
const val ENGINE_CACHE_KEY = "immich::background_worker::engine"
|
||||||
|
|
||||||
|
|
||||||
fun enqueueMediaObserver(ctx: Context) {
|
fun enqueueMediaObserver(ctx: Context) {
|
||||||
val settings = BackgroundWorkerPreferences(ctx).getSettings()
|
val settings = BackgroundWorkerPreferences(ctx).getSettings()
|
||||||
val constraints = Constraints.Builder().apply {
|
val constraints = Constraints.Builder().apply {
|
||||||
|
|||||||
@@ -44,10 +44,11 @@ private open class BackgroundWorkerLockPigeonCodec : StandardMessageCodec() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
interface BackgroundWorkerLockApi {
|
interface BackgroundWorkerLockApi {
|
||||||
fun lock()
|
fun lock(callback: (Result<Unit>) -> Unit)
|
||||||
fun unlock()
|
fun unlock(callback: (Result<Unit>) -> Unit)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The codec used by BackgroundWorkerLockApi. */
|
/** The codec used by BackgroundWorkerLockApi. */
|
||||||
@@ -62,13 +63,14 @@ interface BackgroundWorkerLockApi {
|
|||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerLockApi.lock$separatedMessageChannelSuffix", codec)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerLockApi.lock$separatedMessageChannelSuffix", codec)
|
||||||
if (api != null) {
|
if (api != null) {
|
||||||
channel.setMessageHandler { _, reply ->
|
channel.setMessageHandler { _, reply ->
|
||||||
val wrapped: List<Any?> = try {
|
api.lock{ result: Result<Unit> ->
|
||||||
api.lock()
|
val error = result.exceptionOrNull()
|
||||||
listOf(null)
|
if (error != null) {
|
||||||
} catch (exception: Throwable) {
|
reply.reply(BackgroundWorkerLockPigeonUtils.wrapError(error))
|
||||||
BackgroundWorkerLockPigeonUtils.wrapError(exception)
|
} else {
|
||||||
|
reply.reply(BackgroundWorkerLockPigeonUtils.wrapResult(null))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
reply.reply(wrapped)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
@@ -78,13 +80,14 @@ interface BackgroundWorkerLockApi {
|
|||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerLockApi.unlock$separatedMessageChannelSuffix", codec)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerLockApi.unlock$separatedMessageChannelSuffix", codec)
|
||||||
if (api != null) {
|
if (api != null) {
|
||||||
channel.setMessageHandler { _, reply ->
|
channel.setMessageHandler { _, reply ->
|
||||||
val wrapped: List<Any?> = try {
|
api.unlock{ result: Result<Unit> ->
|
||||||
api.unlock()
|
val error = result.exceptionOrNull()
|
||||||
listOf(null)
|
if (error != null) {
|
||||||
} catch (exception: Throwable) {
|
reply.reply(BackgroundWorkerLockPigeonUtils.wrapError(error))
|
||||||
BackgroundWorkerLockPigeonUtils.wrapError(exception)
|
} else {
|
||||||
|
reply.reply(BackgroundWorkerLockPigeonUtils.wrapResult(null))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
reply.reply(wrapped)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
|
|||||||
@@ -82,9 +82,10 @@ private open class ConnectivityPigeonCodec : StandardMessageCodec() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
interface ConnectivityApi {
|
interface ConnectivityApi {
|
||||||
fun getCapabilities(): List<NetworkCapability>
|
fun getCapabilities(callback: (Result<List<NetworkCapability>>) -> Unit)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The codec used by ConnectivityApi. */
|
/** The codec used by ConnectivityApi. */
|
||||||
@@ -100,12 +101,15 @@ interface ConnectivityApi {
|
|||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ConnectivityApi.getCapabilities$separatedMessageChannelSuffix", codec, taskQueue)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ConnectivityApi.getCapabilities$separatedMessageChannelSuffix", codec, taskQueue)
|
||||||
if (api != null) {
|
if (api != null) {
|
||||||
channel.setMessageHandler { _, reply ->
|
channel.setMessageHandler { _, reply ->
|
||||||
val wrapped: List<Any?> = try {
|
api.getCapabilities{ result: Result<List<NetworkCapability>> ->
|
||||||
listOf(api.getCapabilities())
|
val error = result.exceptionOrNull()
|
||||||
} catch (exception: Throwable) {
|
if (error != null) {
|
||||||
ConnectivityPigeonUtils.wrapError(exception)
|
reply.reply(ConnectivityPigeonUtils.wrapError(error))
|
||||||
|
} else {
|
||||||
|
val data = result.getOrNull()
|
||||||
|
reply.reply(ConnectivityPigeonUtils.wrapResult(data))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
reply.reply(wrapped)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import android.net.wifi.WifiManager
|
import android.net.wifi.WifiManager
|
||||||
|
import app.alextran.immich.dispatch
|
||||||
|
|
||||||
class ConnectivityApiImpl(context: Context) : ConnectivityApi {
|
class ConnectivityApiImpl(context: Context) : ConnectivityApi {
|
||||||
private val connectivityManager =
|
private val connectivityManager =
|
||||||
@@ -11,7 +12,13 @@ class ConnectivityApiImpl(context: Context) : ConnectivityApi {
|
|||||||
private val wifiManager =
|
private val wifiManager =
|
||||||
context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
||||||
|
|
||||||
override fun getCapabilities(): List<NetworkCapability> {
|
|
||||||
|
override fun getCapabilities(callback: (Result<List<NetworkCapability>>) -> Unit) =
|
||||||
|
dispatch(callback = callback) {
|
||||||
|
getCapabilities()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getCapabilities(): List<NetworkCapability> {
|
||||||
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
|
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
|
||||||
?: return emptyList()
|
?: return emptyList()
|
||||||
|
|
||||||
|
|||||||
@@ -296,13 +296,13 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
|||||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
interface NativeSyncApi {
|
interface NativeSyncApi {
|
||||||
fun shouldFullSync(): Boolean
|
fun shouldFullSync(): Boolean
|
||||||
fun getMediaChanges(): SyncDelta
|
fun getMediaChanges(callback: (Result<SyncDelta>) -> Unit)
|
||||||
fun checkpointSync()
|
fun checkpointSync()
|
||||||
fun clearSyncCheckpoint()
|
fun clearSyncCheckpoint()
|
||||||
fun getAssetIdsForAlbum(albumId: String): List<String>
|
fun getAssetIdsForAlbum(albumId: String, callback: (Result<List<String>>) -> Unit)
|
||||||
fun getAlbums(): List<PlatformAlbum>
|
fun getAlbums(callback: (Result<List<PlatformAlbum>>) -> Unit)
|
||||||
fun getAssetsCountSince(albumId: String, timestamp: Long): Long
|
fun getAssetsCountSince(albumId: String, timestamp: Long, callback: (Result<Long>) -> Unit)
|
||||||
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset>
|
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?, callback: (Result<List<PlatformAsset>>) -> Unit)
|
||||||
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
|
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
|
||||||
fun cancelHashing()
|
fun cancelHashing()
|
||||||
|
|
||||||
@@ -335,12 +335,15 @@ interface NativeSyncApi {
|
|||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$separatedMessageChannelSuffix", codec, taskQueue)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$separatedMessageChannelSuffix", codec, taskQueue)
|
||||||
if (api != null) {
|
if (api != null) {
|
||||||
channel.setMessageHandler { _, reply ->
|
channel.setMessageHandler { _, reply ->
|
||||||
val wrapped: List<Any?> = try {
|
api.getMediaChanges{ result: Result<SyncDelta> ->
|
||||||
listOf(api.getMediaChanges())
|
val error = result.exceptionOrNull()
|
||||||
} catch (exception: Throwable) {
|
if (error != null) {
|
||||||
MessagesPigeonUtils.wrapError(exception)
|
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||||
|
} else {
|
||||||
|
val data = result.getOrNull()
|
||||||
|
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
reply.reply(wrapped)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
@@ -384,12 +387,15 @@ interface NativeSyncApi {
|
|||||||
channel.setMessageHandler { message, reply ->
|
channel.setMessageHandler { message, reply ->
|
||||||
val args = message as List<Any?>
|
val args = message as List<Any?>
|
||||||
val albumIdArg = args[0] as String
|
val albumIdArg = args[0] as String
|
||||||
val wrapped: List<Any?> = try {
|
api.getAssetIdsForAlbum(albumIdArg) { result: Result<List<String>> ->
|
||||||
listOf(api.getAssetIdsForAlbum(albumIdArg))
|
val error = result.exceptionOrNull()
|
||||||
} catch (exception: Throwable) {
|
if (error != null) {
|
||||||
MessagesPigeonUtils.wrapError(exception)
|
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||||
|
} else {
|
||||||
|
val data = result.getOrNull()
|
||||||
|
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
reply.reply(wrapped)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
@@ -399,12 +405,15 @@ interface NativeSyncApi {
|
|||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$separatedMessageChannelSuffix", codec, taskQueue)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$separatedMessageChannelSuffix", codec, taskQueue)
|
||||||
if (api != null) {
|
if (api != null) {
|
||||||
channel.setMessageHandler { _, reply ->
|
channel.setMessageHandler { _, reply ->
|
||||||
val wrapped: List<Any?> = try {
|
api.getAlbums{ result: Result<List<PlatformAlbum>> ->
|
||||||
listOf(api.getAlbums())
|
val error = result.exceptionOrNull()
|
||||||
} catch (exception: Throwable) {
|
if (error != null) {
|
||||||
MessagesPigeonUtils.wrapError(exception)
|
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||||
|
} else {
|
||||||
|
val data = result.getOrNull()
|
||||||
|
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
reply.reply(wrapped)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
@@ -417,12 +426,15 @@ interface NativeSyncApi {
|
|||||||
val args = message as List<Any?>
|
val args = message as List<Any?>
|
||||||
val albumIdArg = args[0] as String
|
val albumIdArg = args[0] as String
|
||||||
val timestampArg = args[1] as Long
|
val timestampArg = args[1] as Long
|
||||||
val wrapped: List<Any?> = try {
|
api.getAssetsCountSince(albumIdArg, timestampArg) { result: Result<Long> ->
|
||||||
listOf(api.getAssetsCountSince(albumIdArg, timestampArg))
|
val error = result.exceptionOrNull()
|
||||||
} catch (exception: Throwable) {
|
if (error != null) {
|
||||||
MessagesPigeonUtils.wrapError(exception)
|
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||||
|
} else {
|
||||||
|
val data = result.getOrNull()
|
||||||
|
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
reply.reply(wrapped)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
@@ -435,12 +447,15 @@ interface NativeSyncApi {
|
|||||||
val args = message as List<Any?>
|
val args = message as List<Any?>
|
||||||
val albumIdArg = args[0] as String
|
val albumIdArg = args[0] as String
|
||||||
val updatedTimeCondArg = args[1] as Long?
|
val updatedTimeCondArg = args[1] as Long?
|
||||||
val wrapped: List<Any?> = try {
|
api.getAssetsForAlbum(albumIdArg, updatedTimeCondArg) { result: Result<List<PlatformAsset>> ->
|
||||||
listOf(api.getAssetsForAlbum(albumIdArg, updatedTimeCondArg))
|
val error = result.exceptionOrNull()
|
||||||
} catch (exception: Throwable) {
|
if (error != null) {
|
||||||
MessagesPigeonUtils.wrapError(exception)
|
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||||
|
} else {
|
||||||
|
val data = result.getOrNull()
|
||||||
|
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
reply.reply(wrapped)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class NativeSyncApiImpl26(context: Context) : NativeSyncApiImplBase(context), Na
|
|||||||
// No-op for Android 10 and below
|
// No-op for Android 10 and below
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getMediaChanges(): SyncDelta {
|
override fun getMediaChanges(callback: (Result<SyncDelta>) -> Unit) =
|
||||||
throw IllegalStateException("Method not supported on this Android version.")
|
callback(Result.failure(IllegalStateException("Method not supported on this Android version.")))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.os.Build
|
|||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.annotation.RequiresExtension
|
import androidx.annotation.RequiresExtension
|
||||||
|
import app.alextran.immich.dispatch
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
@@ -47,7 +48,12 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getMediaChanges(): SyncDelta {
|
override fun getMediaChanges(callback: (Result<SyncDelta>) -> Unit) =
|
||||||
|
dispatch(callback = callback) {
|
||||||
|
getMediaChanges()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getMediaChanges(): SyncDelta {
|
||||||
val genMap = getSavedGenerationMap()
|
val genMap = getSavedGenerationMap()
|
||||||
val currentVolumes = MediaStore.getExternalVolumeNames(ctx)
|
val currentVolumes = MediaStore.getExternalVolumeNames(ctx)
|
||||||
val changed = mutableListOf<PlatformAsset>()
|
val changed = mutableListOf<PlatformAsset>()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import android.database.Cursor
|
|||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import androidx.core.database.getStringOrNull
|
import androidx.core.database.getStringOrNull
|
||||||
|
import app.alextran.immich.dispatch
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -145,7 +146,10 @@ open class NativeSyncApiImplBase(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAlbums(): List<PlatformAlbum> {
|
fun getAlbums(callback: (Result<List<PlatformAlbum>>) -> Unit) =
|
||||||
|
dispatch(callback = callback) { getAlbums() }
|
||||||
|
|
||||||
|
private fun getAlbums(): List<PlatformAlbum> {
|
||||||
val albums = mutableListOf<PlatformAlbum>()
|
val albums = mutableListOf<PlatformAlbum>()
|
||||||
val albumsCount = mutableMapOf<String, Int>()
|
val albumsCount = mutableMapOf<String, Int>()
|
||||||
|
|
||||||
@@ -192,7 +196,10 @@ open class NativeSyncApiImplBase(context: Context) {
|
|||||||
.sortedBy { it.id }
|
.sortedBy { it.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAssetIdsForAlbum(albumId: String): List<String> {
|
fun getAssetIdsForAlbum(albumId: String, callback: (Result<List<String>>) -> Unit) =
|
||||||
|
dispatch(callback = callback) { getAssetIdsForAlbum(albumId); }
|
||||||
|
|
||||||
|
private fun getAssetIdsForAlbum(albumId: String): List<String> {
|
||||||
val projection = arrayOf(MediaStore.MediaColumns._ID)
|
val projection = arrayOf(MediaStore.MediaColumns._ID)
|
||||||
|
|
||||||
return getCursor(
|
return getCursor(
|
||||||
@@ -208,15 +215,23 @@ open class NativeSyncApiImplBase(context: Context) {
|
|||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAssetsCountSince(albumId: String, timestamp: Long): Long =
|
fun getAssetsCountSince(albumId: String, timestamp: Long, callback: (Result<Long>) -> Unit) =
|
||||||
|
dispatch(callback = callback) { getAssetsCountSince(albumId, timestamp) }
|
||||||
|
|
||||||
|
private fun getAssetsCountSince(albumId: String, timestamp: Long): Long =
|
||||||
getCursor(
|
getCursor(
|
||||||
MediaStore.VOLUME_EXTERNAL,
|
MediaStore.VOLUME_EXTERNAL,
|
||||||
"$BUCKET_SELECTION AND ${MediaStore.Files.FileColumns.DATE_ADDED} > ? AND $MEDIA_SELECTION",
|
"$BUCKET_SELECTION AND ${MediaStore.Files.FileColumns.DATE_ADDED} > ? AND $MEDIA_SELECTION",
|
||||||
arrayOf(albumId, timestamp.toString(), *MEDIA_SELECTION_ARGS),
|
arrayOf(albumId, timestamp.toString(), *MEDIA_SELECTION_ARGS),
|
||||||
)?.use { cursor -> cursor.count.toLong() } ?: 0L
|
)?.use { cursor -> cursor.count.toLong() } ?: 0L
|
||||||
|
|
||||||
|
fun getAssetsForAlbum(
|
||||||
|
albumId: String,
|
||||||
|
updatedTimeCond: Long?,
|
||||||
|
callback: (Result<List<PlatformAsset>>) -> Unit
|
||||||
|
) = dispatch(callback = callback) { getAssetsForAlbum(albumId, updatedTimeCond) }
|
||||||
|
|
||||||
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset> {
|
private fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset> {
|
||||||
var selection = "$BUCKET_SELECTION AND $MEDIA_SELECTION"
|
var selection = "$BUCKET_SELECTION AND $MEDIA_SELECTION"
|
||||||
val selectionArgs = mutableListOf(albumId, *MEDIA_SELECTION_ARGS)
|
val selectionArgs = mutableListOf(albumId, *MEDIA_SELECTION_ARGS)
|
||||||
|
|
||||||
@@ -254,7 +269,7 @@ open class NativeSyncApiImplBase(context: Context) {
|
|||||||
}.awaitAll()
|
}.awaitAll()
|
||||||
|
|
||||||
callback(Result.success(results))
|
callback(Result.success(results))
|
||||||
} catch (e: CancellationException) {
|
} catch (_: CancellationException) {
|
||||||
callback(
|
callback(
|
||||||
Result.failure(
|
Result.failure(
|
||||||
FlutterError(
|
FlutterError(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 54;
|
objectVersion = 77;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
@@ -133,11 +133,14 @@
|
|||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
|
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
);
|
|
||||||
path = Sync;
|
path = Sync;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
B2D27ABE2E84A0FF004DD55B /* Core */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = Core;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
F0B57D3D2DF764BD00DC5BCC /* WidgetExtension */ = {
|
F0B57D3D2DF764BD00DC5BCC /* WidgetExtension */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
exceptions = (
|
||||||
@@ -247,6 +250,7 @@
|
|||||||
97C146F01CF9000F007C117D /* Runner */ = {
|
97C146F01CF9000F007C117D /* Runner */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
B2D27ABE2E84A0FF004DD55B /* Core */,
|
||||||
B25D37792E72CA15008B6CA7 /* Connectivity */,
|
B25D37792E72CA15008B6CA7 /* Connectivity */,
|
||||||
B21E34A62E5AF9760031FDB9 /* Background */,
|
B21E34A62E5AF9760031FDB9 /* Background */,
|
||||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
||||||
@@ -332,6 +336,7 @@
|
|||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
||||||
|
B2D27ABE2E84A0FF004DD55B /* Core */,
|
||||||
);
|
);
|
||||||
name = Runner;
|
name = Runner;
|
||||||
productName = Runner;
|
productName = Runner;
|
||||||
@@ -521,10 +526,14 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
name = "[CP] Copy Pods Resources";
|
name = "[CP] Copy Pods Resources";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||||
@@ -553,10 +562,14 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
name = "[CP] Embed Pods Frameworks";
|
name = "[CP] Embed Pods Frameworks";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||||
|
|||||||
@@ -179,11 +179,12 @@ class BackgroundWorkerPigeonCodec: FlutterStandardMessageCodec, @unchecked Senda
|
|||||||
static let shared = BackgroundWorkerPigeonCodec(readerWriter: BackgroundWorkerPigeonCodecReaderWriter())
|
static let shared = BackgroundWorkerPigeonCodec(readerWriter: BackgroundWorkerPigeonCodecReaderWriter())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||||
protocol BackgroundWorkerFgHostApi {
|
protocol BackgroundWorkerFgHostApi {
|
||||||
func enable() throws
|
func enable(completion: @escaping (Result<Void, Error>) -> Void)
|
||||||
func configure(settings: BackgroundWorkerSettings) throws
|
func configure(settings: BackgroundWorkerSettings, completion: @escaping (Result<Void, Error>) -> Void)
|
||||||
func disable() throws
|
func disable(completion: @escaping (Result<Void, Error>) -> Void)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||||
@@ -195,11 +196,13 @@ class BackgroundWorkerFgHostApiSetup {
|
|||||||
let enableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
let enableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
if let api = api {
|
if let api = api {
|
||||||
enableChannel.setMessageHandler { _, reply in
|
enableChannel.setMessageHandler { _, reply in
|
||||||
do {
|
api.enable { result in
|
||||||
try api.enable()
|
switch result {
|
||||||
reply(wrapResult(nil))
|
case .success:
|
||||||
} catch {
|
reply(wrapResult(nil))
|
||||||
reply(wrapError(error))
|
case .failure(let error):
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -210,11 +213,13 @@ class BackgroundWorkerFgHostApiSetup {
|
|||||||
configureChannel.setMessageHandler { message, reply in
|
configureChannel.setMessageHandler { message, reply in
|
||||||
let args = message as! [Any?]
|
let args = message as! [Any?]
|
||||||
let settingsArg = args[0] as! BackgroundWorkerSettings
|
let settingsArg = args[0] as! BackgroundWorkerSettings
|
||||||
do {
|
api.configure(settings: settingsArg) { result in
|
||||||
try api.configure(settings: settingsArg)
|
switch result {
|
||||||
reply(wrapResult(nil))
|
case .success:
|
||||||
} catch {
|
reply(wrapResult(nil))
|
||||||
reply(wrapError(error))
|
case .failure(let error):
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -223,11 +228,13 @@ class BackgroundWorkerFgHostApiSetup {
|
|||||||
let disableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
let disableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
if let api = api {
|
if let api = api {
|
||||||
disableChannel.setMessageHandler { _, reply in
|
disableChannel.setMessageHandler { _, reply in
|
||||||
do {
|
api.disable { result in
|
||||||
try api.disable()
|
switch result {
|
||||||
reply(wrapResult(nil))
|
case .success:
|
||||||
} catch {
|
reply(wrapResult(nil))
|
||||||
reply(wrapError(error))
|
case .failure(let error):
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
import BackgroundTasks
|
import BackgroundTasks
|
||||||
|
|
||||||
class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||||
|
func enable(completion: @escaping (Result<Void, any Error>) -> Void) {
|
||||||
func enable() throws {
|
dispatch(completion: completion) {
|
||||||
BackgroundWorkerApiImpl.scheduleRefreshWorker()
|
BackgroundWorkerApiImpl.scheduleRefreshWorker()
|
||||||
BackgroundWorkerApiImpl.scheduleProcessingWorker()
|
BackgroundWorkerApiImpl.scheduleProcessingWorker()
|
||||||
print("BackgroundWorkerApiImpl:enable Background worker scheduled")
|
print("BackgroundWorkerApiImpl:enable Background worker scheduled")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func configure(settings: BackgroundWorkerSettings) throws {
|
func configure(settings: BackgroundWorkerSettings, completion: @escaping (Result<Void, any Error>) -> Void) {
|
||||||
// Android only
|
// Android only
|
||||||
|
completion(Result.success(Void()))
|
||||||
}
|
}
|
||||||
|
|
||||||
func disable() throws {
|
func disable(completion: @escaping (Result<Void, any Error>) -> Void) {
|
||||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.refreshTaskID);
|
dispatch(completion: completion) {
|
||||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.processingTaskID);
|
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.refreshTaskID);
|
||||||
print("BackgroundWorkerApiImpl:disableUploadWorker Disabled background workers")
|
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.processingTaskID);
|
||||||
|
print("BackgroundWorkerApiImpl:disableUploadWorker Disabled background workers")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let refreshTaskID = "app.alextran.immich.background.refreshUpload"
|
private static let refreshTaskID = "app.alextran.immich.background.refreshUpload"
|
||||||
|
|||||||
@@ -94,9 +94,10 @@ class ConnectivityPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable
|
|||||||
static let shared = ConnectivityPigeonCodec(readerWriter: ConnectivityPigeonCodecReaderWriter())
|
static let shared = ConnectivityPigeonCodec(readerWriter: ConnectivityPigeonCodecReaderWriter())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||||
protocol ConnectivityApi {
|
protocol ConnectivityApi {
|
||||||
func getCapabilities() throws -> [NetworkCapability]
|
func getCapabilities(completion: @escaping (Result<[NetworkCapability], Error>) -> Void)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||||
@@ -115,11 +116,13 @@ class ConnectivityApiSetup {
|
|||||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ConnectivityApi.getCapabilities\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ConnectivityApi.getCapabilities\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||||
if let api = api {
|
if let api = api {
|
||||||
getCapabilitiesChannel.setMessageHandler { _, reply in
|
getCapabilitiesChannel.setMessageHandler { _, reply in
|
||||||
do {
|
api.getCapabilities { result in
|
||||||
let result = try api.getCapabilities()
|
switch result {
|
||||||
reply(wrapResult(result))
|
case .success(let res):
|
||||||
} catch {
|
reply(wrapResult(res))
|
||||||
reply(wrapError(error))
|
case .failure(let error):
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
class ConnectivityApiImpl: ConnectivityApi {
|
class ConnectivityApiImpl: ConnectivityApi {
|
||||||
func getCapabilities() throws -> [NetworkCapability] {
|
func getCapabilities(completion: @escaping (Result<[NetworkCapability], any Error>) -> Void) {
|
||||||
[]
|
completion(Result.success([]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
9
mobile/ios/Runner/Core/ImmichPlugin.swift
Normal file
9
mobile/ios/Runner/Core/ImmichPlugin.swift
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
func dispatch<T>(
|
||||||
|
qos: DispatchQoS.QoSClass = .default,
|
||||||
|
completion: @escaping (Result<T, Error>) -> Void,
|
||||||
|
block: @escaping () throws -> T
|
||||||
|
) {
|
||||||
|
DispatchQueue.global(qos: qos).async {
|
||||||
|
completion(Result { try block() })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -355,13 +355,13 @@ class MessagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
|
|||||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||||
protocol NativeSyncApi {
|
protocol NativeSyncApi {
|
||||||
func shouldFullSync() throws -> Bool
|
func shouldFullSync() throws -> Bool
|
||||||
func getMediaChanges() throws -> SyncDelta
|
func getMediaChanges(completion: @escaping (Result<SyncDelta, Error>) -> Void)
|
||||||
func checkpointSync() throws
|
func checkpointSync() throws
|
||||||
func clearSyncCheckpoint() throws
|
func clearSyncCheckpoint() throws
|
||||||
func getAssetIdsForAlbum(albumId: String) throws -> [String]
|
func getAssetIdsForAlbum(albumId: String, completion: @escaping (Result<[String], Error>) -> Void)
|
||||||
func getAlbums() throws -> [PlatformAlbum]
|
func getAlbums(completion: @escaping (Result<[PlatformAlbum], Error>) -> Void)
|
||||||
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64
|
func getAssetsCountSince(albumId: String, timestamp: Int64, completion: @escaping (Result<Int64, Error>) -> Void)
|
||||||
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
|
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?, completion: @escaping (Result<[PlatformAsset], Error>) -> Void)
|
||||||
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
|
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
|
||||||
func cancelHashing() throws
|
func cancelHashing() throws
|
||||||
}
|
}
|
||||||
@@ -395,11 +395,13 @@ class NativeSyncApiSetup {
|
|||||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||||
if let api = api {
|
if let api = api {
|
||||||
getMediaChangesChannel.setMessageHandler { _, reply in
|
getMediaChangesChannel.setMessageHandler { _, reply in
|
||||||
do {
|
api.getMediaChanges { result in
|
||||||
let result = try api.getMediaChanges()
|
switch result {
|
||||||
reply(wrapResult(result))
|
case .success(let res):
|
||||||
} catch {
|
reply(wrapResult(res))
|
||||||
reply(wrapError(error))
|
case .failure(let error):
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -438,11 +440,13 @@ class NativeSyncApiSetup {
|
|||||||
getAssetIdsForAlbumChannel.setMessageHandler { message, reply in
|
getAssetIdsForAlbumChannel.setMessageHandler { message, reply in
|
||||||
let args = message as! [Any?]
|
let args = message as! [Any?]
|
||||||
let albumIdArg = args[0] as! String
|
let albumIdArg = args[0] as! String
|
||||||
do {
|
api.getAssetIdsForAlbum(albumId: albumIdArg) { result in
|
||||||
let result = try api.getAssetIdsForAlbum(albumId: albumIdArg)
|
switch result {
|
||||||
reply(wrapResult(result))
|
case .success(let res):
|
||||||
} catch {
|
reply(wrapResult(res))
|
||||||
reply(wrapError(error))
|
case .failure(let error):
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -453,11 +457,13 @@ class NativeSyncApiSetup {
|
|||||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||||
if let api = api {
|
if let api = api {
|
||||||
getAlbumsChannel.setMessageHandler { _, reply in
|
getAlbumsChannel.setMessageHandler { _, reply in
|
||||||
do {
|
api.getAlbums { result in
|
||||||
let result = try api.getAlbums()
|
switch result {
|
||||||
reply(wrapResult(result))
|
case .success(let res):
|
||||||
} catch {
|
reply(wrapResult(res))
|
||||||
reply(wrapError(error))
|
case .failure(let error):
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -471,11 +477,13 @@ class NativeSyncApiSetup {
|
|||||||
let args = message as! [Any?]
|
let args = message as! [Any?]
|
||||||
let albumIdArg = args[0] as! String
|
let albumIdArg = args[0] as! String
|
||||||
let timestampArg = args[1] as! Int64
|
let timestampArg = args[1] as! Int64
|
||||||
do {
|
api.getAssetsCountSince(albumId: albumIdArg, timestamp: timestampArg) { result in
|
||||||
let result = try api.getAssetsCountSince(albumId: albumIdArg, timestamp: timestampArg)
|
switch result {
|
||||||
reply(wrapResult(result))
|
case .success(let res):
|
||||||
} catch {
|
reply(wrapResult(res))
|
||||||
reply(wrapError(error))
|
case .failure(let error):
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -489,11 +497,13 @@ class NativeSyncApiSetup {
|
|||||||
let args = message as! [Any?]
|
let args = message as! [Any?]
|
||||||
let albumIdArg = args[0] as! String
|
let albumIdArg = args[0] as! String
|
||||||
let updatedTimeCondArg: Int64? = nilOrValue(args[1])
|
let updatedTimeCondArg: Int64? = nilOrValue(args[1])
|
||||||
do {
|
api.getAssetsForAlbum(albumId: albumIdArg, updatedTimeCond: updatedTimeCondArg) { result in
|
||||||
let result = try api.getAssetsForAlbum(albumId: albumIdArg, updatedTimeCond: updatedTimeCondArg)
|
switch result {
|
||||||
reply(wrapResult(result))
|
case .success(let res):
|
||||||
} catch {
|
reply(wrapResult(res))
|
||||||
reply(wrapError(error))
|
case .failure(let error):
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ struct AssetWrapper: Hashable, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class NativeSyncApiImpl: NativeSyncApi {
|
class NativeSyncApiImpl: NativeSyncApi {
|
||||||
|
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
private let changeTokenKey = "immich:changeToken"
|
private let changeTokenKey = "immich:changeToken"
|
||||||
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
|
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
|
||||||
@@ -75,7 +76,12 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAlbums() throws -> [PlatformAlbum] {
|
|
||||||
|
func getAlbums(completion: @escaping (Result<[PlatformAlbum], any Error>) -> Void) {
|
||||||
|
dispatch(qos: .userInitiated, completion: completion, block: getAlbums)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getAlbums() throws -> [PlatformAlbum] {
|
||||||
var albums: [PlatformAlbum] = []
|
var albums: [PlatformAlbum] = []
|
||||||
|
|
||||||
albumTypes.forEach { type in
|
albumTypes.forEach { type in
|
||||||
@@ -112,7 +118,11 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||||||
return albums.sorted { $0.id < $1.id }
|
return albums.sorted { $0.id < $1.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
func getMediaChanges() throws -> SyncDelta {
|
func getMediaChanges(completion: @escaping (Result<SyncDelta, any Error>) -> Void) {
|
||||||
|
dispatch(qos: .userInitiated, completion: completion, block: getMediaChanges)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getMediaChanges() throws -> SyncDelta {
|
||||||
guard #available(iOS 16, *) else {
|
guard #available(iOS 16, *) else {
|
||||||
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil)
|
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil)
|
||||||
}
|
}
|
||||||
@@ -198,7 +208,11 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||||||
return albumAssets
|
return albumAssets
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAssetIdsForAlbum(albumId: String) throws -> [String] {
|
func getAssetIdsForAlbum(albumId: String, completion: @escaping (Result<[String], any Error>) -> Void) {
|
||||||
|
dispatch(qos: .userInitiated, completion: completion) { try self.getAssetIdsForAlbum(albumId: albumId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getAssetIdsForAlbum(albumId: String) throws -> [String] {
|
||||||
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
|
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
|
||||||
guard let album = collections.firstObject else {
|
guard let album = collections.firstObject else {
|
||||||
return []
|
return []
|
||||||
@@ -214,7 +228,13 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 {
|
func getAssetsCountSince(albumId: String, timestamp: Int64, completion: @escaping (Result<Int64, any Error>) -> Void) {
|
||||||
|
dispatch(qos: .userInitiated, completion: completion) {
|
||||||
|
try self.getAssetsCountSince(albumId: albumId, timestamp: timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 {
|
||||||
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
|
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
|
||||||
guard let album = collections.firstObject else {
|
guard let album = collections.firstObject else {
|
||||||
return 0
|
return 0
|
||||||
@@ -228,7 +248,13 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||||||
return Int64(assets.count)
|
return Int64(assets.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] {
|
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?, completion: @escaping (Result<[PlatformAsset], any Error>) -> Void) {
|
||||||
|
dispatch(qos: .userInitiated, completion: completion) {
|
||||||
|
try self.getAssetsForAlbum(albumId: albumId, updatedTimeCond: updatedTimeCond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] {
|
||||||
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
|
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
|
||||||
guard let album = collections.firstObject else {
|
guard let album = collections.firstObject else {
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
|||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
await _backgroundHostApi.showNotification(
|
await _backgroundHostApi.showNotification(
|
||||||
IntlKeys.uploading_media.t(),
|
IntlKeys.uploading_media.t(),
|
||||||
IntlKeys.backup_background_service_in_progress_notification.t(),
|
IntlKeys.backup_background_service_default_notification.t(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
|
extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
|
||||||
LocalAsset toDto() => LocalAsset(
|
LocalAsset toDto({String? remoteId}) => LocalAsset(
|
||||||
id: id,
|
id: id,
|
||||||
name: name,
|
name: name,
|
||||||
checksum: checksum,
|
checksum: checksum,
|
||||||
@@ -32,7 +32,7 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
|
|||||||
isFavorite: isFavorite,
|
isFavorite: isFavorite,
|
||||||
height: height,
|
height: height,
|
||||||
width: width,
|
width: width,
|
||||||
remoteId: null,
|
remoteId: remoteId,
|
||||||
orientation: orientation,
|
orientation: orientation,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
|
extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
|
||||||
RemoteAsset toDto() => RemoteAsset(
|
RemoteAsset toDto({String? localId}) => RemoteAsset(
|
||||||
id: id,
|
id: id,
|
||||||
name: name,
|
name: name,
|
||||||
ownerId: ownerId,
|
ownerId: ownerId,
|
||||||
@@ -64,7 +64,7 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
|
|||||||
thumbHash: thumbHash,
|
thumbHash: thumbHash,
|
||||||
visibility: visibility,
|
visibility: visibility,
|
||||||
livePhotoVideoId: livePhotoVideoId,
|
livePhotoVideoId: livePhotoVideoId,
|
||||||
localId: null,
|
localId: localId,
|
||||||
stackId: stackId,
|
stackId: stackId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,10 +148,9 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
|||||||
..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)])
|
..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)])
|
||||||
..limit(count, offset: offset);
|
..limit(count, offset: offset);
|
||||||
|
|
||||||
return query.map((row) {
|
return query
|
||||||
final asset = row.readTable(_db.localAssetEntity).toDto();
|
.map((row) => row.readTable(_db.localAssetEntity).toDto(remoteId: row.read(_db.remoteAssetEntity.id)))
|
||||||
return asset.copyWith(remoteId: row.read(_db.remoteAssetEntity.id));
|
.get();
|
||||||
}).get();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
TimelineQuery remoteAlbum(String albumId, GroupAssetsBy groupBy) => (
|
TimelineQuery remoteAlbum(String albumId, GroupAssetsBy groupBy) => (
|
||||||
@@ -165,17 +164,15 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
|||||||
.count(where: (row) => row.albumId.equals(albumId))
|
.count(where: (row) => row.albumId.equals(albumId))
|
||||||
.map(_generateBuckets)
|
.map(_generateBuckets)
|
||||||
.watch()
|
.watch()
|
||||||
.map((results) => results.isNotEmpty ? results.first : <Bucket>[])
|
.map((results) => results.isNotEmpty ? results.first : const <Bucket>[])
|
||||||
.handleError((error) {
|
.handleError((error) => const <Bucket>[]);
|
||||||
return [];
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (_db.remoteAlbumEntity.select()..where((row) => row.id.equals(albumId)))
|
return (_db.remoteAlbumEntity.select()..where((row) => row.id.equals(albumId)))
|
||||||
.watch()
|
.watch()
|
||||||
.switchMap((albums) {
|
.switchMap((albums) {
|
||||||
if (albums.isEmpty) {
|
if (albums.isEmpty) {
|
||||||
return Stream.value(<Bucket>[]);
|
return Stream.value(const <Bucket>[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
final album = albums.first;
|
final album = albums.first;
|
||||||
@@ -207,10 +204,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
|||||||
return TimeBucket(date: timeline, assetCount: assetCount);
|
return TimeBucket(date: timeline, assetCount: assetCount);
|
||||||
}).watch();
|
}).watch();
|
||||||
})
|
})
|
||||||
.handleError((error) {
|
// If there's an error (e.g., album was deleted), return empty buckets
|
||||||
// If there's an error (e.g., album was deleted), return empty buckets
|
.handleError((error) => const <Bucket>[]);
|
||||||
return <Bucket>[];
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<BaseAsset>> _getRemoteAlbumBucketAssets(String albumId, {required int offset, required int count}) async {
|
Future<List<BaseAsset>> _getRemoteAlbumBucketAssets(String albumId, {required int offset, required int count}) async {
|
||||||
@@ -218,17 +213,22 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
|||||||
|
|
||||||
// If album doesn't exist (was deleted), return empty list
|
// If album doesn't exist (was deleted), return empty list
|
||||||
if (albumData == null) {
|
if (albumData == null) {
|
||||||
return <BaseAsset>[];
|
return const <BaseAsset>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
final isAscending = albumData.order == AlbumAssetOrder.asc;
|
final isAscending = albumData.order == AlbumAssetOrder.asc;
|
||||||
|
|
||||||
final query = _db.remoteAssetEntity.select().join([
|
final query = _db.remoteAssetEntity.select().addColumns([_db.localAssetEntity.id]).join([
|
||||||
innerJoin(
|
innerJoin(
|
||||||
_db.remoteAlbumAssetEntity,
|
_db.remoteAlbumAssetEntity,
|
||||||
_db.remoteAlbumAssetEntity.assetId.equalsExp(_db.remoteAssetEntity.id),
|
_db.remoteAlbumAssetEntity.assetId.equalsExp(_db.remoteAssetEntity.id),
|
||||||
useColumns: false,
|
useColumns: false,
|
||||||
),
|
),
|
||||||
|
leftOuterJoin(
|
||||||
|
_db.localAssetEntity,
|
||||||
|
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
|
||||||
|
useColumns: false,
|
||||||
|
),
|
||||||
])..where(_db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAlbumAssetEntity.albumId.equals(albumId));
|
])..where(_db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAlbumAssetEntity.albumId.equals(albumId));
|
||||||
|
|
||||||
if (isAscending) {
|
if (isAscending) {
|
||||||
@@ -239,12 +239,14 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
|||||||
|
|
||||||
query.limit(count, offset: offset);
|
query.limit(count, offset: offset);
|
||||||
|
|
||||||
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
|
return query
|
||||||
|
.map((row) => row.readTable(_db.remoteAssetEntity).toDto(localId: row.read(_db.localAssetEntity.id)))
|
||||||
|
.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
TimelineQuery fromAssets(List<BaseAsset> assets) => (
|
TimelineQuery fromAssets(List<BaseAsset> assets) => (
|
||||||
bucketSource: () => Stream.value(_generateBuckets(assets.length)),
|
bucketSource: () => Stream.value(_generateBuckets(assets.length)),
|
||||||
assetSource: (offset, count) => Future.value(assets.skip(offset).take(count).toList()),
|
assetSource: (offset, count) => Future.value(assets.skip(offset).take(count).toList(growable: false)),
|
||||||
);
|
);
|
||||||
|
|
||||||
TimelineQuery remote(String ownerId, GroupAssetsBy groupBy) => _remoteQueryBuilder(
|
TimelineQuery remote(String ownerId, GroupAssetsBy groupBy) => _remoteQueryBuilder(
|
||||||
@@ -486,6 +488,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
|||||||
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
|
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
TimelineQuery _remoteQueryBuilder({
|
TimelineQuery _remoteQueryBuilder({
|
||||||
required Expression<bool> Function($RemoteAssetEntityTable row) filter,
|
required Expression<bool> Function($RemoteAssetEntityTable row) filter,
|
||||||
GroupAssetsBy groupBy = GroupAssetsBy.day,
|
GroupAssetsBy groupBy = GroupAssetsBy.day,
|
||||||
@@ -523,6 +526,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
|||||||
}).watch();
|
}).watch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
Future<List<BaseAsset>> _getRemoteAssets({
|
Future<List<BaseAsset>> _getRemoteAssets({
|
||||||
required Expression<bool> Function($RemoteAssetEntityTable row) filter,
|
required Expression<bool> Function($RemoteAssetEntityTable row) filter,
|
||||||
required int offset,
|
required int offset,
|
||||||
@@ -543,11 +547,9 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
|||||||
..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)])
|
..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)])
|
||||||
..limit(count, offset: offset);
|
..limit(count, offset: offset);
|
||||||
|
|
||||||
return query.map((row) {
|
return query
|
||||||
final asset = row.readTable(_db.remoteAssetEntity).toDto();
|
.map((row) => row.readTable(_db.remoteAssetEntity).toDto(localId: row.read(_db.localAssetEntity.id)))
|
||||||
final localId = row.read(_db.localAssetEntity.id);
|
.get();
|
||||||
return asset.copyWith(localId: localId);
|
|
||||||
}).get();
|
|
||||||
} else {
|
} else {
|
||||||
final query = _db.remoteAssetEntity.select()
|
final query = _db.remoteAssetEntity.select()
|
||||||
..where(filter)
|
..where(filter)
|
||||||
@@ -560,12 +562,12 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<Bucket> _generateBuckets(int count) {
|
List<Bucket> _generateBuckets(int count) {
|
||||||
final buckets = List.generate(
|
final buckets = List.filled(
|
||||||
(count / kTimelineNoneSegmentSize).floor(),
|
(count / kTimelineNoneSegmentSize).ceil(),
|
||||||
(_) => const Bucket(assetCount: kTimelineNoneSegmentSize),
|
const Bucket(assetCount: kTimelineNoneSegmentSize),
|
||||||
);
|
);
|
||||||
if (count % kTimelineNoneSegmentSize != 0) {
|
if (count % kTimelineNoneSegmentSize != 0) {
|
||||||
buckets.add(Bucket(assetCount: count % kTimelineNoneSegmentSize));
|
buckets[buckets.length - 1] = Bucket(assetCount: count % kTimelineNoneSegmentSize);
|
||||||
}
|
}
|
||||||
return buckets;
|
return buckets;
|
||||||
}
|
}
|
||||||
@@ -590,10 +592,6 @@ extension on String {
|
|||||||
GroupAssetsBy.month => "y-M",
|
GroupAssetsBy.month => "y-M",
|
||||||
GroupAssetsBy.none => throw ArgumentError("GroupAssetsBy.none is not supported for date formatting"),
|
GroupAssetsBy.none => throw ArgumentError("GroupAssetsBy.none is not supported for date formatting"),
|
||||||
};
|
};
|
||||||
try {
|
return DateFormat(format, 'en').parse(this);
|
||||||
return DateFormat(format, 'en').parse(this);
|
|
||||||
} catch (e) {
|
|
||||||
throw FormatException("Invalid date format: $this", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,9 +25,10 @@ class DriftMapPage extends StatelessWidget {
|
|||||||
onPressed: () => context.pop(),
|
onPressed: () => context.pop(),
|
||||||
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
shape: const CircleBorder(side: BorderSide(width: 1, color: Colors.black26)),
|
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
backgroundColor: Colors.indigo.withValues(alpha: 0.7),
|
backgroundColor: Colors.indigo,
|
||||||
|
shadowColor: Colors.black26,
|
||||||
|
elevation: 4,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class UnStackActionButton extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return BaseActionButton(
|
return BaseActionButton(
|
||||||
iconData: Icons.filter_none_rounded,
|
iconData: Icons.layers_clear_outlined,
|
||||||
label: "unstack".t(context: context),
|
label: "unstack".t(context: context),
|
||||||
onPressed: () => _onTap(context, ref),
|
onPressed: () => _onTap(context, ref),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
isArchived: isArchived,
|
isArchived: isArchived,
|
||||||
isTrashEnabled: isTrashEnable,
|
isTrashEnabled: isTrashEnable,
|
||||||
isInLockedView: isInLockedView,
|
isInLockedView: isInLockedView,
|
||||||
|
isStacked: asset.hasRemote && (asset as RemoteAsset).stackId != null,
|
||||||
currentAlbum: currentAlbum,
|
currentAlbum: currentAlbum,
|
||||||
advancedTroubleshooting: advancedTroubleshooting,
|
advancedTroubleshooting: advancedTroubleshooting,
|
||||||
source: ActionSource.viewer,
|
source: ActionSource.viewer,
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||||
|
|
||||||
final actions = <Widget>[
|
final actions = <Widget>[
|
||||||
if (asset.hasRemote) const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
|
if (asset.isRemoteOnly) const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
|
||||||
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
|
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
|
||||||
if (album != null && album.isActivityEnabled && album.isShared)
|
if (album != null && album.isActivityEnabled && album.isShared)
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_act
|
|||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/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/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/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/action_buttons/upload_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
@@ -44,6 +45,7 @@ class ArchiveBottomSheet extends ConsumerWidget {
|
|||||||
const EditLocationActionButton(source: ActionSource.timeline),
|
const EditLocationActionButton(source: ActionSource.timeline),
|
||||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
|
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
|
||||||
|
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
|
||||||
],
|
],
|
||||||
if (multiselect.hasLocal) ...[
|
if (multiselect.hasLocal) ...[
|
||||||
const DeleteLocalActionButton(source: ActionSource.timeline),
|
const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_act
|
|||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/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/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/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/action_buttons/upload_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
@@ -44,6 +45,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
|
|||||||
const EditLocationActionButton(source: ActionSource.timeline),
|
const EditLocationActionButton(source: ActionSource.timeline),
|
||||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
|
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
|
||||||
|
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
|
||||||
],
|
],
|
||||||
if (multiselect.hasLocal) ...[
|
if (multiselect.hasLocal) ...[
|
||||||
const DeleteLocalActionButton(source: ActionSource.timeline),
|
const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:immich_mobile/constants/enums.dart';
|
|||||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/setting.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/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/archive_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
||||||
@@ -19,6 +20,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b
|
|||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/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/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/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/action_buttons/upload_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.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';
|
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||||
@@ -62,11 +64,19 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final remoteAssets = selectedAssets.whereType<RemoteAsset>();
|
||||||
final addedCount = await ref
|
final addedCount = await ref
|
||||||
.read(remoteAlbumProvider.notifier)
|
.read(remoteAlbumProvider.notifier)
|
||||||
.addAssets(album.id, selectedAssets.map((e) => (e as RemoteAsset).id).toList());
|
.addAssets(album.id, remoteAssets.map((e) => e.id).toList());
|
||||||
|
|
||||||
if (addedCount != selectedAssets.length) {
|
if (selectedAssets.length != remoteAssets.length) {
|
||||||
|
ImmichToast.show(
|
||||||
|
context: context,
|
||||||
|
msg: 'add_to_album_bottom_sheet_some_local_assets'.t(context: context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addedCount != remoteAssets.length) {
|
||||||
ImmichToast.show(
|
ImmichToast.show(
|
||||||
context: context,
|
context: context,
|
||||||
msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {"album": album.name}),
|
msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {"album": album.name}),
|
||||||
@@ -108,15 +118,18 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
|||||||
const EditLocationActionButton(source: ActionSource.timeline),
|
const EditLocationActionButton(source: ActionSource.timeline),
|
||||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.selectedAssets.length > 1) const StackActionButton(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),
|
const DeleteActionButton(source: ActionSource.timeline),
|
||||||
],
|
],
|
||||||
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
|
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.hasLocal) const UploadActionButton(source: ActionSource.timeline),
|
if (multiselect.hasLocal) const UploadActionButton(source: ActionSource.timeline),
|
||||||
],
|
],
|
||||||
slivers: [
|
slivers: multiselect.hasRemote
|
||||||
const AddToAlbumHeader(),
|
? [
|
||||||
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
|
const AddToAlbumHeader(),
|
||||||
],
|
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
|
||||||
|
]
|
||||||
|
: [],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
|
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||||
@@ -10,13 +11,14 @@ class MapBottomSheet extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const BaseBottomSheet(
|
return BaseBottomSheet(
|
||||||
initialChildSize: 0.25,
|
initialChildSize: 0.25,
|
||||||
maxChildSize: 0.9,
|
maxChildSize: 0.9,
|
||||||
shouldCloseOnMinExtent: false,
|
shouldCloseOnMinExtent: false,
|
||||||
resizeOnScroll: false,
|
resizeOnScroll: false,
|
||||||
actions: [],
|
actions: [],
|
||||||
slivers: [SliverFillRemaining(hasScrollBody: false, child: _ScopedMapTimeline())],
|
backgroundColor: context.themeData.colorScheme.surface,
|
||||||
|
slivers: [const SliverFillRemaining(hasScrollBody: false, child: _ScopedMapTimeline())],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b
|
|||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/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/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/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/action_buttons/upload_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.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';
|
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||||
@@ -102,6 +103,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
|||||||
const EditLocationActionButton(source: ActionSource.timeline),
|
const EditLocationActionButton(source: ActionSource.timeline),
|
||||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
|
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
|
||||||
|
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
|
||||||
],
|
],
|
||||||
if (multiselect.hasLocal) ...[
|
if (multiselect.hasLocal) ...[
|
||||||
const DeleteLocalActionButton(source: ActionSource.timeline),
|
const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||||
|
|||||||
@@ -123,28 +123,14 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
|
|||||||
return provider;
|
return provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Size size = kThumbnailResolution}) {
|
ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution}) {
|
||||||
assert(asset != null || remoteId != null, 'Either asset or remoteId must be provided');
|
if (_shouldUseLocalAsset(asset)) {
|
||||||
|
|
||||||
if (remoteId != null) {
|
|
||||||
return RemoteThumbProvider(assetId: remoteId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_shouldUseLocalAsset(asset!)) {
|
|
||||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
|
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
|
||||||
return LocalThumbProvider(id: id, size: size, assetType: asset.type);
|
return LocalThumbProvider(id: id, size: size, assetType: asset.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
final String assetId;
|
final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId;
|
||||||
if (asset is LocalAsset && asset.hasRemote) {
|
return assetId != null ? RemoteThumbProvider(assetId: assetId) : null;
|
||||||
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) =>
|
bool _shouldUseLocalAsset(BaseAsset asset) =>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_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/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/remote_image_provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
|
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||||
@@ -39,14 +38,7 @@ class Thumbnail extends StatefulWidget {
|
|||||||
),
|
),
|
||||||
_ => null,
|
_ => null,
|
||||||
},
|
},
|
||||||
imageProvider = switch (asset) {
|
imageProvider = asset == null ? null : getThumbnailImageProvider(asset, size: size);
|
||||||
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
|
@override
|
||||||
State<Thumbnail> createState() => _ThumbnailState();
|
State<Thumbnail> createState() => _ThumbnailState();
|
||||||
|
|||||||
@@ -54,8 +54,6 @@ class ThumbnailTile extends ConsumerWidget {
|
|||||||
)
|
)
|
||||||
: const BoxDecoration();
|
: const BoxDecoration();
|
||||||
|
|
||||||
final hasStack = asset is RemoteAsset && asset.stackId != null;
|
|
||||||
|
|
||||||
final bool storageIndicator =
|
final bool storageIndicator =
|
||||||
showStorageIndicator ?? ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator)));
|
showStorageIndicator ?? ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator)));
|
||||||
|
|
||||||
@@ -77,21 +75,10 @@ class ThumbnailTile extends ConsumerWidget {
|
|||||||
child: Thumbnail.fromAsset(asset: asset, size: size),
|
child: Thumbnail.fromAsset(asset: asset, size: size),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (hasStack)
|
if (asset != null)
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.topRight,
|
alignment: Alignment.topRight,
|
||||||
child: Padding(
|
child: _AssetTypeIcons(asset: asset),
|
||||||
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)
|
if (storageIndicator && asset != null)
|
||||||
switch (asset.storage) {
|
switch (asset.storage) {
|
||||||
@@ -214,3 +201,34 @@ class _TileOverlayIcon extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _AssetTypeIcons extends StatelessWidget {
|
||||||
|
final BaseAsset asset;
|
||||||
|
|
||||||
|
const _AssetTypeIcons({required this.asset});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final hasStack = asset is RemoteAsset && (asset as RemoteAsset).stackId != null;
|
||||||
|
final isLivePhoto = asset is RemoteAsset && asset.livePhotoVideoId != null;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
if (asset.isVideo)
|
||||||
|
Padding(padding: const EdgeInsets.only(right: 10.0, top: 6.0), child: _VideoIndicator(asset.duration)),
|
||||||
|
if (hasStack)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(right: 10.0, top: 6.0),
|
||||||
|
child: _TileOverlayIcon(Icons.burst_mode_rounded),
|
||||||
|
),
|
||||||
|
if (isLivePhoto)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(right: 10.0, top: 6.0),
|
||||||
|
child: _TileOverlayIcon(Icons.motion_photos_on_rounded),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
@@ -9,8 +10,8 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
|||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_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/bottom_sheet/map_bottom_sheet.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/map/map_utils.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
|
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/map/map_utils.dart';
|
||||||
import 'package:immich_mobile/utils/async_mutex.dart';
|
import 'package:immich_mobile/utils/async_mutex.dart';
|
||||||
import 'package:immich_mobile/utils/debounce.dart';
|
import 'package:immich_mobile/utils/debounce.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
@@ -187,6 +188,8 @@ class _Map extends StatelessWidget {
|
|||||||
styleString: style,
|
styleString: style,
|
||||||
onMapCreated: onMapCreated,
|
onMapCreated: onMapCreated,
|
||||||
onStyleLoadedCallback: onMapReady,
|
onStyleLoadedCallback: onMapReady,
|
||||||
|
attributionButtonPosition: AttributionButtonPosition.topRight,
|
||||||
|
attributionButtonMargins: Platform.isIOS ? const Point(40, 12) : const Point(40, 72),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -212,11 +212,14 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
if (fallbackSegment != null) {
|
if (fallbackSegment != null) {
|
||||||
// Scroll to the segment with a small offset to show the header
|
// Scroll to the segment with a small offset to show the header
|
||||||
final targetOffset = fallbackSegment.startOffset - 50;
|
final targetOffset = fallbackSegment.startOffset - 50;
|
||||||
_scrollController.animateTo(
|
ref.read(timelineStateProvider.notifier).setScrubbing(true);
|
||||||
targetOffset.clamp(0.0, _scrollController.position.maxScrollExtent),
|
_scrollController
|
||||||
duration: const Duration(milliseconds: 500),
|
.animateTo(
|
||||||
curve: Curves.easeInOut,
|
targetOffset.clamp(0.0, _scrollController.position.maxScrollExtent),
|
||||||
);
|
duration: const Duration(milliseconds: 500),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
)
|
||||||
|
.whenComplete(() => ref.read(timelineStateProvider.notifier).setScrubbing(false));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:background_downloader/background_downloader.dart';
|
import 'package:background_downloader/background_downloader.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
@@ -12,8 +11,8 @@ import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart
|
|||||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/services/upload.service.dart';
|
import 'package:immich_mobile/services/upload.service.dart';
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
import 'package:immich_mobile/utils/debug_print.dart';
|
import 'package:immich_mobile/utils/debug_print.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
class EnqueueStatus {
|
class EnqueueStatus {
|
||||||
final int enqueueCount;
|
final int enqueueCount;
|
||||||
@@ -90,33 +89,6 @@ class DriftUploadStatus {
|
|||||||
networkSpeedAsString.hashCode ^
|
networkSpeedAsString.hashCode ^
|
||||||
isFailed.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 {
|
class DriftBackupState {
|
||||||
@@ -267,6 +239,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
state = state.copyWith(uploadItems: {...state.uploadItems, taskId: currentItem.copyWith(isFailed: true)});
|
state = state.copyWith(uploadItems: {...state.uploadItems, taskId: currentItem.copyWith(isFailed: true)});
|
||||||
|
_logger.fine("Upload failed for taskId: $taskId, exception: ${update.exception}");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case TaskStatus.canceled:
|
case TaskStatus.canceled:
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import 'package:background_downloader/background_downloader.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||||
import 'package:immich_mobile/models/download/livephotos_medatada.model.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/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
@@ -36,6 +39,7 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
late ActionService _service;
|
late ActionService _service;
|
||||||
late UploadService _uploadService;
|
late UploadService _uploadService;
|
||||||
late DownloadService _downloadService;
|
late DownloadService _downloadService;
|
||||||
|
late AssetService _assetService;
|
||||||
|
|
||||||
ActionNotifier() : super();
|
ActionNotifier() : super();
|
||||||
|
|
||||||
@@ -43,6 +47,7 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
void build() {
|
void build() {
|
||||||
_uploadService = ref.watch(uploadServiceProvider);
|
_uploadService = ref.watch(uploadServiceProvider);
|
||||||
_service = ref.watch(actionServiceProvider);
|
_service = ref.watch(actionServiceProvider);
|
||||||
|
_assetService = ref.watch(assetServiceProvider);
|
||||||
_downloadService = ref.watch(downloadServiceProvider);
|
_downloadService = ref.watch(downloadServiceProvider);
|
||||||
_downloadService.onImageDownloadStatus = _downloadImageCallback;
|
_downloadService.onImageDownloadStatus = _downloadImageCallback;
|
||||||
_downloadService.onVideoDownloadStatus = _downloadVideoCallback;
|
_downloadService.onVideoDownloadStatus = _downloadVideoCallback;
|
||||||
@@ -335,6 +340,14 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
final assets = _getOwnedRemoteAssetsForSource(source);
|
final assets = _getOwnedRemoteAssetsForSource(source);
|
||||||
try {
|
try {
|
||||||
await _service.unStack(assets.map((e) => e.stackId).nonNulls.toList());
|
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);
|
return ActionResult(count: assets.length, success: true);
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
_logger.severe('Failed to unstack assets', error, stack);
|
_logger.severe('Failed to unstack assets', error, stack);
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ class MultiSelectState {
|
|||||||
bool get hasRemote =>
|
bool get hasRemote =>
|
||||||
selectedAssets.any((asset) => asset.storage == AssetState.remote || asset.storage == AssetState.merged);
|
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 hasLocal => selectedAssets.any((asset) => asset.storage == AssetState.local);
|
||||||
|
|
||||||
bool get hasMerged => selectedAssets.any((asset) => asset.storage == AssetState.merged);
|
bool get hasMerged => selectedAssets.any((asset) => asset.storage == AssetState.merged);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:immich_mobile/constants/constants.dart';
|
|||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.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/backup.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||||
@@ -19,9 +20,9 @@ import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
|||||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.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:logging/logging.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:immich_mobile/utils/debug_print.dart';
|
|
||||||
|
|
||||||
final uploadServiceProvider = Provider((ref) {
|
final uploadServiceProvider = Provider((ref) {
|
||||||
final service = UploadService(
|
final service = UploadService(
|
||||||
@@ -205,10 +206,20 @@ class UploadService {
|
|||||||
return _uploadRepository.start();
|
return _uploadRepository.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleTaskStatusUpdate(TaskStatusUpdate update) {
|
void _handleTaskStatusUpdate(TaskStatusUpdate update) async {
|
||||||
switch (update.status) {
|
switch (update.status) {
|
||||||
case TaskStatus.complete:
|
case TaskStatus.complete:
|
||||||
_handleLivePhoto(update);
|
_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;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b
|
|||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/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/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/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/action_buttons/upload_action_button.widget.dart';
|
||||||
|
|
||||||
class ActionButtonContext {
|
class ActionButtonContext {
|
||||||
@@ -24,6 +25,7 @@ class ActionButtonContext {
|
|||||||
final bool isArchived;
|
final bool isArchived;
|
||||||
final bool isTrashEnabled;
|
final bool isTrashEnabled;
|
||||||
final bool isInLockedView;
|
final bool isInLockedView;
|
||||||
|
final bool isStacked;
|
||||||
final RemoteAlbum? currentAlbum;
|
final RemoteAlbum? currentAlbum;
|
||||||
final bool advancedTroubleshooting;
|
final bool advancedTroubleshooting;
|
||||||
final ActionSource source;
|
final ActionSource source;
|
||||||
@@ -33,6 +35,7 @@ class ActionButtonContext {
|
|||||||
required this.isOwner,
|
required this.isOwner,
|
||||||
required this.isArchived,
|
required this.isArchived,
|
||||||
required this.isTrashEnabled,
|
required this.isTrashEnabled,
|
||||||
|
required this.isStacked,
|
||||||
required this.isInLockedView,
|
required this.isInLockedView,
|
||||||
required this.currentAlbum,
|
required this.currentAlbum,
|
||||||
required this.advancedTroubleshooting,
|
required this.advancedTroubleshooting,
|
||||||
@@ -55,6 +58,7 @@ enum ActionButtonType {
|
|||||||
deleteLocal,
|
deleteLocal,
|
||||||
upload,
|
upload,
|
||||||
removeFromAlbum,
|
removeFromAlbum,
|
||||||
|
unstack,
|
||||||
likeActivity;
|
likeActivity;
|
||||||
|
|
||||||
bool shouldShow(ActionButtonContext context) {
|
bool shouldShow(ActionButtonContext context) {
|
||||||
@@ -110,6 +114,10 @@ enum ActionButtonType {
|
|||||||
context.isOwner && //
|
context.isOwner && //
|
||||||
!context.isInLockedView && //
|
!context.isInLockedView && //
|
||||||
context.currentAlbum != null,
|
context.currentAlbum != null,
|
||||||
|
ActionButtonType.unstack =>
|
||||||
|
context.isOwner && //
|
||||||
|
!context.isInLockedView && //
|
||||||
|
context.isStacked,
|
||||||
ActionButtonType.likeActivity =>
|
ActionButtonType.likeActivity =>
|
||||||
!context.isInLockedView &&
|
!context.isInLockedView &&
|
||||||
context.currentAlbum != null &&
|
context.currentAlbum != null &&
|
||||||
@@ -138,28 +146,13 @@ enum ActionButtonType {
|
|||||||
source: context.source,
|
source: context.source,
|
||||||
),
|
),
|
||||||
ActionButtonType.likeActivity => const LikeActivityActionButton(),
|
ActionButtonType.likeActivity => const LikeActivityActionButton(),
|
||||||
|
ActionButtonType.unstack => UnStackActionButton(source: context.source),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ActionButtonBuilder {
|
class ActionButtonBuilder {
|
||||||
static const List<ActionButtonType> _actionTypes = [
|
static const List<ActionButtonType> _actionTypes = ActionButtonType.values;
|
||||||
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) {
|
static List<Widget> build(ActionButtonContext context) {
|
||||||
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
|
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
|
||||||
|
|||||||
@@ -129,19 +129,24 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
title: Builder(
|
title: Builder(
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext context) {
|
||||||
return Row(
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Builder(
|
Padding(
|
||||||
builder: (context) {
|
padding: const EdgeInsets.only(top: 3.0),
|
||||||
return Padding(
|
child: SvgPicture.asset(
|
||||||
padding: const EdgeInsets.only(top: 3.0),
|
context.isDarkTheme ? 'assets/immich-logo-inline-dark.svg' : 'assets/immich-logo-inline-light.svg',
|
||||||
child: SvgPicture.asset(
|
height: 40,
|
||||||
context.isDarkTheme
|
),
|
||||||
? 'assets/immich-logo-inline-dark.svg'
|
),
|
||||||
: 'assets/immich-logo-inline-light.svg',
|
const Tooltip(
|
||||||
height: 40,
|
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),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,10 +20,13 @@ class BackgroundWorkerSettings {
|
|||||||
|
|
||||||
@HostApi()
|
@HostApi()
|
||||||
abstract class BackgroundWorkerFgHostApi {
|
abstract class BackgroundWorkerFgHostApi {
|
||||||
|
@async
|
||||||
void enable();
|
void enable();
|
||||||
|
|
||||||
|
@async
|
||||||
void configure(BackgroundWorkerSettings settings);
|
void configure(BackgroundWorkerSettings settings);
|
||||||
|
|
||||||
|
@async
|
||||||
void disable();
|
void disable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import 'package:pigeon/pigeon.dart';
|
|||||||
)
|
)
|
||||||
@HostApi()
|
@HostApi()
|
||||||
abstract class BackgroundWorkerLockApi {
|
abstract class BackgroundWorkerLockApi {
|
||||||
|
@async
|
||||||
void lock();
|
void lock();
|
||||||
|
|
||||||
|
@async
|
||||||
void unlock();
|
void unlock();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ enum NetworkCapability { cellular, wifi, vpn, unmetered }
|
|||||||
|
|
||||||
@HostApi()
|
@HostApi()
|
||||||
abstract class ConnectivityApi {
|
abstract class ConnectivityApi {
|
||||||
|
@async
|
||||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||||
List<NetworkCapability> getCapabilities();
|
List<NetworkCapability> getCapabilities();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ class HashResult {
|
|||||||
abstract class NativeSyncApi {
|
abstract class NativeSyncApi {
|
||||||
bool shouldFullSync();
|
bool shouldFullSync();
|
||||||
|
|
||||||
|
@async
|
||||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||||
SyncDelta getMediaChanges();
|
SyncDelta getMediaChanges();
|
||||||
|
|
||||||
@@ -90,15 +91,19 @@ abstract class NativeSyncApi {
|
|||||||
|
|
||||||
void clearSyncCheckpoint();
|
void clearSyncCheckpoint();
|
||||||
|
|
||||||
|
@async
|
||||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||||
List<String> getAssetIdsForAlbum(String albumId);
|
List<String> getAssetIdsForAlbum(String albumId);
|
||||||
|
|
||||||
|
@async
|
||||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||||
List<PlatformAlbum> getAlbums();
|
List<PlatformAlbum> getAlbums();
|
||||||
|
|
||||||
|
@async
|
||||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||||
int getAssetsCountSince(String albumId, int timestamp);
|
int getAssetsCountSince(String albumId, int timestamp);
|
||||||
|
|
||||||
|
@async
|
||||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||||
List<PlatformAsset> getAssetsForAlbum(String albumId, {int? updatedTimeCond});
|
List<PlatformAsset> getAssetsForAlbum(String albumId, {int? updatedTimeCond});
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -112,6 +113,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -127,6 +129,7 @@ void main() {
|
|||||||
isInLockedView: true,
|
isInLockedView: true,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -145,6 +148,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -161,6 +165,7 @@ void main() {
|
|||||||
isInLockedView: true,
|
isInLockedView: true,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -177,6 +182,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -195,6 +201,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -211,6 +218,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -227,6 +235,7 @@ void main() {
|
|||||||
isInLockedView: true,
|
isInLockedView: true,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -243,6 +252,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -259,6 +269,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -277,6 +288,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -293,6 +305,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -309,6 +322,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -327,6 +341,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -343,6 +358,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -359,6 +375,7 @@ void main() {
|
|||||||
isInLockedView: true,
|
isInLockedView: true,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -377,6 +394,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -393,6 +411,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -411,6 +430,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -427,6 +447,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -445,6 +466,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -463,6 +485,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -481,6 +504,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -497,6 +521,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -512,6 +537,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -530,6 +556,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -548,6 +575,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: album,
|
currentAlbum: album,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -563,6 +591,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -581,6 +610,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: album,
|
currentAlbum: album,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -597,6 +627,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: album,
|
currentAlbum: album,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -613,6 +644,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: album,
|
currentAlbum: album,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -628,6 +660,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -645,6 +678,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: true,
|
advancedTroubleshooting: true,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -660,6 +694,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -668,6 +703,59 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('unstack button', () {
|
||||||
|
test('should show when owner, not locked, has remote, and is stacked', () {
|
||||||
|
final remoteAsset = createRemoteAsset();
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: remoteAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: true,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.unstack.shouldShow(context), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not show when not stacked', () {
|
||||||
|
final remoteAsset = createRemoteAsset();
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: remoteAsset,
|
||||||
|
isOwner: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.unstack.shouldShow(context), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not show when not owner', () {
|
||||||
|
final remoteAsset = createRemoteAsset();
|
||||||
|
final context = ActionButtonContext(
|
||||||
|
asset: remoteAsset,
|
||||||
|
isOwner: false,
|
||||||
|
isArchived: true,
|
||||||
|
isTrashEnabled: true,
|
||||||
|
isInLockedView: false,
|
||||||
|
currentAlbum: null,
|
||||||
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
|
source: ActionSource.timeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ActionButtonType.unstack.shouldShow(context), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('ActionButtonType.buildButton', () {
|
group('ActionButtonType.buildButton', () {
|
||||||
late BaseAsset asset;
|
late BaseAsset asset;
|
||||||
late ActionButtonContext context;
|
late ActionButtonContext context;
|
||||||
@@ -682,6 +770,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -698,6 +787,22 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: album,
|
currentAlbum: album,
|
||||||
advancedTroubleshooting: false,
|
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,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
final widget = buttonType.buildButton(contextWithAlbum);
|
final widget = buttonType.buildButton(contextWithAlbum);
|
||||||
@@ -721,6 +826,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -741,6 +847,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: album,
|
currentAlbum: album,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -759,6 +866,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -778,6 +886,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -791,6 +900,7 @@ void main() {
|
|||||||
isInLockedView: false,
|
isInLockedView: false,
|
||||||
currentAlbum: null,
|
currentAlbum: null,
|
||||||
advancedTroubleshooting: false,
|
advancedTroubleshooting: false,
|
||||||
|
isStacked: false,
|
||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,4 @@
|
|||||||
FROM ghcr.io/immich-app/base-server-dev:202509210934@sha256:b5ce2d7eaf379d4cf15efd4bab180d8afc8a80d20b36c9800f4091aca6ae267e AS builder
|
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 \
|
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||||
CI=1 \
|
CI=1 \
|
||||||
COREPACK_HOME=/tmp
|
COREPACK_HOME=/tmp
|
||||||
@@ -57,7 +40,6 @@ ENV NODE_ENV=production \
|
|||||||
NVIDIA_DRIVER_CAPABILITIES=all \
|
NVIDIA_DRIVER_CAPABILITIES=all \
|
||||||
NVIDIA_VISIBLE_DEVICES=all
|
NVIDIA_VISIBLE_DEVICES=all
|
||||||
|
|
||||||
COPY --from=builder /usr/bin/tusd /usr/bin/tusd
|
|
||||||
COPY --from=server /output/server-pruned ./server
|
COPY --from=server /output/server-pruned ./server
|
||||||
COPY --from=web /usr/src/app/web/build /build/www
|
COPY --from=web /usr/src/app/web/build /build/www
|
||||||
COPY --from=cli /output/cli-pruned ./cli
|
COPY --from=cli /output/cli-pruned ./cli
|
||||||
|
|||||||
@@ -1,21 +1,6 @@
|
|||||||
# dev build
|
# dev build
|
||||||
FROM ghcr.io/immich-app/base-server-dev:202509210934@sha256:b5ce2d7eaf379d4cf15efd4bab180d8afc8a80d20b36c9800f4091aca6ae267e AS dev
|
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 \
|
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||||
CI=1 \
|
CI=1 \
|
||||||
COREPACK_HOME=/tmp
|
COREPACK_HOME=/tmp
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { getKyselyConfig } from 'src/utils/database';
|
|||||||
const common = [...repositories, ...services, GlobalExceptionFilter];
|
const common = [...repositories, ...services, GlobalExceptionFilter];
|
||||||
|
|
||||||
export const middleware = [
|
export const middleware = [
|
||||||
|
FileUploadInterceptor,
|
||||||
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
||||||
{ provide: APP_PIPE, useValue: new ValidationPipe({ transform: true, whitelist: true }) },
|
{ provide: APP_PIPE, useValue: new ValidationPipe({ transform: true, whitelist: true }) },
|
||||||
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
|
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
|
||||||
@@ -84,7 +85,7 @@ class BaseModule implements OnModuleInit, OnModuleDestroy {
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [...imports, ScheduleModule.forRoot()],
|
imports: [...imports, ScheduleModule.forRoot()],
|
||||||
controllers: [...controllers],
|
controllers: [...controllers],
|
||||||
providers: [...common, ...middleware, FileUploadInterceptor, { provide: IWorker, useValue: ImmichWorker.Api }],
|
providers: [...common, ...middleware, { provide: IWorker, useValue: ImmichWorker.Api }],
|
||||||
})
|
})
|
||||||
export class ApiModule extends BaseModule {}
|
export class ApiModule extends BaseModule {}
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,6 @@ export const ADDED_IN_PREFIX = 'This property was added in ';
|
|||||||
|
|
||||||
export const JOBS_ASSET_PAGINATION_SIZE = 1000;
|
export const JOBS_ASSET_PAGINATION_SIZE = 1000;
|
||||||
export const JOBS_LIBRARY_PAGINATION_SIZE = 10_000;
|
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> = {
|
export const EXTENSION_NAMES: Record<DatabaseExtension, string> = {
|
||||||
cube: 'cube',
|
cube: 'cube',
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
import { All, BadRequestException, Body, Controller, HttpException, Post, Req, Res } from '@nestjs/common';
|
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
|
||||||
import { plainToInstance } from 'class-transformer';
|
|
||||||
import { validateSync } from 'class-validator';
|
|
||||||
import { Request, Response } from 'express';
|
|
||||||
import {
|
|
||||||
TusdHookRequestDto,
|
|
||||||
TusdHookRequestType,
|
|
||||||
TusdHookResponseDto,
|
|
||||||
TusdPreCreateEventDto,
|
|
||||||
TusdPreFinishEventDto,
|
|
||||||
} from 'src/dtos/upload.dto';
|
|
||||||
import { Permission } from 'src/enum';
|
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
|
||||||
import { AssetUploadService } from 'src/services/asset-upload.service';
|
|
||||||
import { AuthService } from 'src/services/auth.service';
|
|
||||||
|
|
||||||
@ApiTags('Upload')
|
|
||||||
@Controller('upload')
|
|
||||||
export class AssetUploadController {
|
|
||||||
constructor(
|
|
||||||
private authService: AuthService,
|
|
||||||
private uploadService: AssetUploadService,
|
|
||||||
private logger: LoggingRepository,
|
|
||||||
) {
|
|
||||||
logger.setContext(AssetUploadController.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Proxies chunked upload requests to the tusd server.
|
|
||||||
// Auth is handled in the pre-create and pre-finish hooks from tusd.
|
|
||||||
@All('asset{/*all}')
|
|
||||||
handleChunks(@Req() request: Request, @Res() response: Response): Promise<unknown> {
|
|
||||||
return this.uploadService.proxyChunks(request, response);
|
|
||||||
}
|
|
||||||
|
|
||||||
// This controller handles webhooks from the tusd server.
|
|
||||||
// See https://tus.github.io/tusd/advanced-topics/hooks/ for more information.
|
|
||||||
@Post('hook')
|
|
||||||
async processHook(@Body() payload: TusdHookRequestDto): Promise<TusdHookResponseDto> {
|
|
||||||
const request = payload.Event.HTTPRequest;
|
|
||||||
const lowerCaseHeaders: Record<string, string> = {};
|
|
||||||
for (const [key, [value]] of Object.entries(request.Header)) {
|
|
||||||
lowerCaseHeaders[key.toLowerCase()] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const auth = await this.authService.authenticate({
|
|
||||||
headers: lowerCaseHeaders,
|
|
||||||
queryParams: {},
|
|
||||||
metadata: { adminRoute: false, sharedLinkRoute: false, permission: Permission.AssetUpload, uri: request.URI },
|
|
||||||
});
|
|
||||||
|
|
||||||
switch (payload.Type) {
|
|
||||||
case TusdHookRequestType.PreCreate: {
|
|
||||||
const dto = plainToInstance(TusdPreCreateEventDto, payload.Event);
|
|
||||||
const errors = validateSync(dto, { whitelist: true });
|
|
||||||
if (errors.length > 0) {
|
|
||||||
throw new BadRequestException('Invalid payload');
|
|
||||||
}
|
|
||||||
return await this.uploadService.handlePreCreate(auth, dto, lowerCaseHeaders);
|
|
||||||
}
|
|
||||||
case TusdHookRequestType.PreFinish: {
|
|
||||||
const dto = plainToInstance(TusdPreFinishEventDto, payload.Event);
|
|
||||||
const errors = validateSync(dto, { whitelist: true });
|
|
||||||
if (errors.length > 0) {
|
|
||||||
throw new BadRequestException('Invalid payload');
|
|
||||||
}
|
|
||||||
return await this.uploadService.handlePreFinish(auth, dto);
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
throw new Error(`Unhandled hook type: ${payload.Type}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error instanceof HttpException) {
|
|
||||||
return { RejectUpload: true, HTTPResponse: { StatusCode: error.getStatus(), Body: error.message } };
|
|
||||||
}
|
|
||||||
this.logger.error('Error processing upload hook', error);
|
|
||||||
return { RejectUpload: true, HTTPResponse: { StatusCode: 500 } };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import { AlbumController } from 'src/controllers/album.controller';
|
|||||||
import { ApiKeyController } from 'src/controllers/api-key.controller';
|
import { ApiKeyController } from 'src/controllers/api-key.controller';
|
||||||
import { AppController } from 'src/controllers/app.controller';
|
import { AppController } from 'src/controllers/app.controller';
|
||||||
import { AssetMediaController } from 'src/controllers/asset-media.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 { AssetController } from 'src/controllers/asset.controller';
|
||||||
import { AuthAdminController } from 'src/controllers/auth-admin.controller';
|
import { AuthAdminController } from 'src/controllers/auth-admin.controller';
|
||||||
import { AuthController } from 'src/controllers/auth.controller';
|
import { AuthController } from 'src/controllers/auth.controller';
|
||||||
@@ -41,7 +40,6 @@ export const controllers = [
|
|||||||
AppController,
|
AppController,
|
||||||
AssetController,
|
AssetController,
|
||||||
AssetMediaController,
|
AssetMediaController,
|
||||||
AssetUploadController,
|
|
||||||
AuthController,
|
AuthController,
|
||||||
AuthAdminController,
|
AuthAdminController,
|
||||||
DownloadController,
|
DownloadController,
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
import { Type } from 'class-transformer';
|
|
||||||
import { IsEnum, IsInt, IsNotEmpty, IsObject, IsString, IsUUID, ValidateNested } from 'class-validator';
|
|
||||||
import { AssetMediaCreateDto } from 'src/dtos/asset-media.dto';
|
|
||||||
|
|
||||||
export enum TusdHookRequestType {
|
|
||||||
PreCreate = 'pre-create',
|
|
||||||
PreFinish = 'pre-finish',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum TusdHookStorageType {
|
|
||||||
FileStore = 'filestore',
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TusdStorageDto {
|
|
||||||
@IsEnum(TusdHookStorageType)
|
|
||||||
Type!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
Path!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
InfoPath!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UploadAssetDataDto extends AssetMediaCreateDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
declare filename: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TusdMetaDataDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
declare AssetData: string; // base64-encoded JSON string of UploadAssetDataDto
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TusdPreCreateUploadDto {
|
|
||||||
@IsInt()
|
|
||||||
Size!: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TusdPreFinishUploadDto {
|
|
||||||
@IsUUID()
|
|
||||||
ID!: string;
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
Size!: number;
|
|
||||||
|
|
||||||
@Type(() => TusdMetaDataDto)
|
|
||||||
@ValidateNested()
|
|
||||||
@IsObject()
|
|
||||||
MetaData!: TusdMetaDataDto;
|
|
||||||
|
|
||||||
@Type(() => TusdStorageDto)
|
|
||||||
@ValidateNested()
|
|
||||||
@IsObject()
|
|
||||||
Storage!: TusdStorageDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TusdHttpRequestDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
Method!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
URI!: string;
|
|
||||||
|
|
||||||
@IsObject()
|
|
||||||
Header!: Record<string, string[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TusdPreCreateEventDto {
|
|
||||||
@Type(() => TusdPreCreateUploadDto)
|
|
||||||
@ValidateNested()
|
|
||||||
@IsObject()
|
|
||||||
Upload!: TusdPreCreateUploadDto;
|
|
||||||
|
|
||||||
@Type(() => TusdHttpRequestDto)
|
|
||||||
@ValidateNested()
|
|
||||||
@IsObject()
|
|
||||||
HTTPRequest!: TusdHttpRequestDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TusdPreFinishEventDto {
|
|
||||||
@Type(() => TusdPreFinishUploadDto)
|
|
||||||
@ValidateNested()
|
|
||||||
@IsObject()
|
|
||||||
Upload!: TusdPreFinishUploadDto;
|
|
||||||
|
|
||||||
@Type(() => TusdHttpRequestDto)
|
|
||||||
@ValidateNested()
|
|
||||||
@IsObject()
|
|
||||||
HTTPRequest!: TusdHttpRequestDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TusdHookRequestDto {
|
|
||||||
@IsEnum(TusdHookRequestType)
|
|
||||||
Type!: TusdHookRequestType;
|
|
||||||
|
|
||||||
@IsObject()
|
|
||||||
Event!: TusdPreCreateEventDto | TusdPreFinishEventDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TusdHttpResponseDto {
|
|
||||||
StatusCode!: number;
|
|
||||||
|
|
||||||
Body?: string;
|
|
||||||
|
|
||||||
Header?: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TusdChangeFileInfoStorageDto {
|
|
||||||
Path?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TusdChangeFileInfoDto {
|
|
||||||
ID?: string;
|
|
||||||
|
|
||||||
MetaData?: TusdMetaDataDto;
|
|
||||||
|
|
||||||
Storage?: TusdChangeFileInfoStorageDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TusdHookResponseDto {
|
|
||||||
HTTPResponse?: TusdHttpResponseDto;
|
|
||||||
|
|
||||||
RejectUpload?: boolean;
|
|
||||||
|
|
||||||
ChangeFileInfo?: TusdChangeFileInfoDto;
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,6 @@ export enum ImmichHeader {
|
|||||||
SharedLinkSlug = 'x-immich-share-slug',
|
SharedLinkSlug = 'x-immich-share-slug',
|
||||||
Checksum = 'x-immich-checksum',
|
Checksum = 'x-immich-checksum',
|
||||||
Cid = 'x-immich-cid',
|
Cid = 'x-immich-cid',
|
||||||
AssetData = 'x-immich-asset-data',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ImmichQuery {
|
export enum ImmichQuery {
|
||||||
@@ -494,7 +493,6 @@ export enum BootstrapEventPriority {
|
|||||||
JobService = -190,
|
JobService = -190,
|
||||||
// Initialise config after other bootstrap services, stop other services from using config on bootstrap
|
// Initialise config after other bootstrap services, stop other services from using config on bootstrap
|
||||||
SystemConfig = 100,
|
SystemConfig = 100,
|
||||||
UploadService = 200,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum QueueName {
|
export enum QueueName {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import archiver from 'archiver';
|
import archiver from 'archiver';
|
||||||
import chokidar, { ChokidarOptions } from 'chokidar';
|
import chokidar, { ChokidarOptions } from 'chokidar';
|
||||||
import { escapePath, glob, globStream } from 'fast-glob';
|
import { escapePath, glob, globStream } from 'fast-glob';
|
||||||
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync } from 'node:fs';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { Readable, Writable } from 'node:stream';
|
import { Readable, Writable } from 'node:stream';
|
||||||
@@ -134,16 +134,6 @@ export class StorageRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
unlinkSync(file: string) {
|
|
||||||
try {
|
|
||||||
unlinkSync(file);
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async unlinkDir(folder: string, options: { recursive?: boolean; force?: boolean }) {
|
async unlinkDir(folder: string, options: { recursive?: boolean; force?: boolean }) {
|
||||||
await fs.rm(folder, options);
|
await fs.rm(folder, options);
|
||||||
}
|
}
|
||||||
@@ -166,13 +156,10 @@ export class StorageRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mkdir(filepath: string): Promise<String | undefined> {
|
|
||||||
return fs.mkdir(filepath, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirSync(filepath: string): void {
|
mkdirSync(filepath: string): void {
|
||||||
// does not throw an error if the folder already exists
|
if (!existsSync(filepath)) {
|
||||||
mkdirSync(filepath, { recursive: true });
|
mkdirSync(filepath, { recursive: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
existsSync(filepath: string) {
|
existsSync(filepath: string) {
|
||||||
|
|||||||
@@ -1,318 +0,0 @@
|
|||||||
import { BadRequestException, Injectable, InternalServerErrorException } from '@nestjs/common';
|
|
||||||
import { plainToInstance } from 'class-transformer';
|
|
||||||
import { validateSync } from 'class-validator';
|
|
||||||
import { Request, Response } from 'express';
|
|
||||||
import { ChildProcess, spawn } from 'node:child_process';
|
|
||||||
import { request as httpRequest } from 'node:http';
|
|
||||||
import { createConnection } from 'node:net';
|
|
||||||
import { extname, join, parse, resolve } from 'node:path';
|
|
||||||
import {
|
|
||||||
UPLOAD_CHUNK_DIRECTORY,
|
|
||||||
UPLOAD_TUSD_CONNECT_BACKOFF_MS,
|
|
||||||
UPLOAD_TUSD_CONNECT_RETRIES,
|
|
||||||
UPLOAD_TUSD_CONNECT_TIMEOUT_MS,
|
|
||||||
UPLOAD_TUSD_SOCKET_PATH,
|
|
||||||
} from 'src/constants';
|
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
|
||||||
import { OnEvent } from 'src/decorators';
|
|
||||||
import { AssetMediaStatus } from 'src/dtos/asset-media-response.dto';
|
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
|
||||||
import {
|
|
||||||
TusdHookResponseDto,
|
|
||||||
TusdPreCreateEventDto,
|
|
||||||
TusdPreFinishEventDto,
|
|
||||||
UploadAssetDataDto,
|
|
||||||
} from 'src/dtos/upload.dto';
|
|
||||||
import { AssetVisibility, ImmichHeader, ImmichWorker, JobName, StorageFolder } from 'src/enum';
|
|
||||||
import { BaseService } from 'src/services/base.service';
|
|
||||||
import { isAssetChecksumConstraint } from 'src/utils/database';
|
|
||||||
import { mimeTypes } from 'src/utils/mime-types';
|
|
||||||
import { fromChecksum } from 'src/utils/request';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AssetUploadService extends BaseService {
|
|
||||||
private tusdProcess?: ChildProcess;
|
|
||||||
|
|
||||||
@OnEvent({ name: 'AppBootstrap', workers: [ImmichWorker.Api] })
|
|
||||||
async onBootstrap() {
|
|
||||||
await this.storageRepository.unlinkDir(UPLOAD_CHUNK_DIRECTORY, { recursive: true, force: true });
|
|
||||||
await this.storageRepository.mkdir(UPLOAD_CHUNK_DIRECTORY);
|
|
||||||
const { host, port } = this.configRepository.getEnv();
|
|
||||||
|
|
||||||
const args = [
|
|
||||||
'-unix-sock',
|
|
||||||
UPLOAD_TUSD_SOCKET_PATH,
|
|
||||||
'--base-path',
|
|
||||||
'/api/upload/asset',
|
|
||||||
'--upload-dir',
|
|
||||||
UPLOAD_CHUNK_DIRECTORY,
|
|
||||||
'--hooks-http',
|
|
||||||
`http://${host ?? 'localhost'}:${port}:/api/upload/hook`,
|
|
||||||
'--hooks-enabled-events',
|
|
||||||
'pre-create,pre-finish',
|
|
||||||
'--behind-proxy',
|
|
||||||
'-enable-experimental-protocol',
|
|
||||||
'-shutdown-timeout',
|
|
||||||
'5s',
|
|
||||||
'-hooks-http-forward-headers',
|
|
||||||
Object.values(ImmichHeader).join(',') + ',authorization',
|
|
||||||
'-disable-download',
|
|
||||||
'-disable-termination',
|
|
||||||
];
|
|
||||||
|
|
||||||
this.logger.log(`Starting tusd server with args: ${args.join(' ')}`);
|
|
||||||
|
|
||||||
this.tusdProcess = spawn('tusd', args, {
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
|
|
||||||
this.tusdProcess.stdout?.on('data', (data) => this.logger.verboseFn(() => `tusd: ${data.toString().trim()}`));
|
|
||||||
|
|
||||||
this.tusdProcess.stderr?.on('data', (data) => this.logger.warn(`tusd: ${data.toString().trim()}`));
|
|
||||||
|
|
||||||
this.tusdProcess.on('error', (error) => {
|
|
||||||
this.logger.error(`tusd failed to start: ${error.message}`);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.tusdProcess.on('exit', async (code) => {
|
|
||||||
this.tusdProcess = undefined;
|
|
||||||
try {
|
|
||||||
await Promise.all([
|
|
||||||
this.storageRepository.unlink(UPLOAD_TUSD_SOCKET_PATH),
|
|
||||||
this.storageRepository.unlinkDir(UPLOAD_CHUNK_DIRECTORY, { recursive: true, force: true }),
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
if (code) {
|
|
||||||
this.logger.error(`tusd exited unexpectedly with code ${code}`);
|
|
||||||
// TODO: more graceful shutdown
|
|
||||||
process.exit(code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log('Waiting for tusd server...');
|
|
||||||
await this.waitForTusd(UPLOAD_TUSD_SOCKET_PATH);
|
|
||||||
this.logger.log('tusd server started successfully');
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnEvent({ name: 'AppShutdown' })
|
|
||||||
onShutdown() {
|
|
||||||
this.tusdProcess?.kill('SIGTERM');
|
|
||||||
this.tusdProcess = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
async proxyChunks(request: Request, response: Response): Promise<unknown> {
|
|
||||||
if (!this.tusdProcess) {
|
|
||||||
throw new InternalServerErrorException('tusd server is not running');
|
|
||||||
}
|
|
||||||
|
|
||||||
delete request.headers.host;
|
|
||||||
request.headers.connection = 'close'; // not sure why, but it doesn't respond for 60 seconds without this
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const proxyReq = httpRequest(
|
|
||||||
{
|
|
||||||
socketPath: UPLOAD_TUSD_SOCKET_PATH,
|
|
||||||
path: request.url,
|
|
||||||
method: request.method,
|
|
||||||
headers: request.headers,
|
|
||||||
},
|
|
||||||
(proxyRes) => {
|
|
||||||
response.status(proxyRes.statusCode || 200);
|
|
||||||
for (const [key, value] of Object.entries(proxyRes.headers)) {
|
|
||||||
if (value !== undefined) {
|
|
||||||
response.setHeader(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
proxyRes.pipe(response);
|
|
||||||
proxyRes.on('end', resolve);
|
|
||||||
proxyRes.on('error', reject);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
proxyReq.on('error', (error: any) => {
|
|
||||||
this.logger.error(`Failed to proxy to tusd: ${error.message}`);
|
|
||||||
reject(new InternalServerErrorException('Upload service unavailable'));
|
|
||||||
});
|
|
||||||
|
|
||||||
if (request.readable) {
|
|
||||||
request.pipe(proxyReq);
|
|
||||||
} else {
|
|
||||||
proxyReq.end();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async waitForTusd(socketPath: string, maxRetries = UPLOAD_TUSD_CONNECT_RETRIES): Promise<void> {
|
|
||||||
for (let i = 0; i < maxRetries; i++) {
|
|
||||||
const ready = await new Promise<boolean>((resolve) => {
|
|
||||||
const socket = createConnection(socketPath, () => {
|
|
||||||
socket.end();
|
|
||||||
resolve(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', () => {
|
|
||||||
resolve(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.setTimeout(UPLOAD_TUSD_CONNECT_TIMEOUT_MS);
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
resolve(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (ready) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, UPLOAD_TUSD_CONNECT_BACKOFF_MS));
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('tusd server failed to start within timeout');
|
|
||||||
}
|
|
||||||
|
|
||||||
async handlePreCreate(
|
|
||||||
auth: AuthDto,
|
|
||||||
payload: TusdPreCreateEventDto,
|
|
||||||
headers: Record<string, string>,
|
|
||||||
): Promise<TusdHookResponseDto> {
|
|
||||||
this.logger.debugFn(() => `PreCreate hook received: ${JSON.stringify(payload)}`);
|
|
||||||
const checksum = headers[ImmichHeader.Checksum]?.[0];
|
|
||||||
if (checksum) {
|
|
||||||
const existingId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, fromChecksum(checksum));
|
|
||||||
if (existingId) {
|
|
||||||
const body = JSON.stringify({ status: AssetMediaStatus.DUPLICATE, id: existingId });
|
|
||||||
return {
|
|
||||||
RejectUpload: true,
|
|
||||||
HTTPResponse: { StatusCode: 200, Body: body },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.requireQuota(auth, payload.Upload.Size);
|
|
||||||
const encodedAssetData = headers[ImmichHeader.AssetData];
|
|
||||||
if (!encodedAssetData) {
|
|
||||||
throw new BadRequestException(`Missing ${ImmichHeader.AssetData} header`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const assetData = this.parseMetadata(encodedAssetData);
|
|
||||||
this.logger.log('assetData: ' + JSON.stringify(assetData));
|
|
||||||
|
|
||||||
const assetId = this.cryptoRepository.randomUUID();
|
|
||||||
const folder = StorageCore.getNestedFolder(StorageFolder.Upload, auth.user.id, assetId);
|
|
||||||
const extension = extname(assetId);
|
|
||||||
const path = join(folder, `${assetId}${extension}`);
|
|
||||||
return {
|
|
||||||
ChangeFileInfo: {
|
|
||||||
ID: assetId,
|
|
||||||
Storage: { Path: path },
|
|
||||||
MetaData: {
|
|
||||||
AssetData: encodedAssetData,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async handlePreFinish(auth: AuthDto, dto: TusdPreFinishEventDto): Promise<TusdHookResponseDto> {
|
|
||||||
this.logger.debugFn(() => `PreFinish hook received: ${JSON.stringify(dto)}`);
|
|
||||||
const {
|
|
||||||
Upload: {
|
|
||||||
MetaData: { AssetData: encodedAssetData },
|
|
||||||
Storage: { Path: path },
|
|
||||||
Size: size,
|
|
||||||
ID: assetId,
|
|
||||||
},
|
|
||||||
} = dto;
|
|
||||||
|
|
||||||
const parsedPath = parse(resolve(path));
|
|
||||||
if (!parsedPath.dir.startsWith(StorageCore.getFolderLocation(StorageFolder.Upload, auth.user.id))) {
|
|
||||||
throw new BadRequestException('Path is not in user folder');
|
|
||||||
}
|
|
||||||
const metadata = this.parseMetadata(encodedAssetData);
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.requireQuota(auth, size);
|
|
||||||
const checksum = await this.cryptoRepository.hashFile(path);
|
|
||||||
await this.storageRepository.utimes(path, new Date(), new Date(metadata.fileModifiedAt));
|
|
||||||
try {
|
|
||||||
await this.assetRepository.create({
|
|
||||||
id: assetId,
|
|
||||||
ownerId: auth.user.id,
|
|
||||||
libraryId: null,
|
|
||||||
checksum,
|
|
||||||
originalPath: path,
|
|
||||||
deviceAssetId: metadata.deviceAssetId,
|
|
||||||
deviceId: metadata.deviceId,
|
|
||||||
fileCreatedAt: metadata.fileCreatedAt,
|
|
||||||
fileModifiedAt: metadata.fileModifiedAt,
|
|
||||||
localDateTime: metadata.fileCreatedAt,
|
|
||||||
type: mimeTypes.assetType(path),
|
|
||||||
isFavorite: metadata.isFavorite,
|
|
||||||
duration: metadata.duration || null,
|
|
||||||
visibility: metadata.visibility || AssetVisibility.Timeline,
|
|
||||||
originalFileName: metadata.filename || parsedPath.base,
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
if (isAssetChecksumConstraint(error)) {
|
|
||||||
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, checksum);
|
|
||||||
if (!duplicateId) {
|
|
||||||
this.logger.error(`Error locating duplicate for checksum constraint`);
|
|
||||||
throw new InternalServerErrorException();
|
|
||||||
}
|
|
||||||
void this.tryUnlink(path);
|
|
||||||
const body = JSON.stringify({ id: duplicateId, status: AssetMediaStatus.DUPLICATE });
|
|
||||||
return { HTTPResponse: { StatusCode: 200, Body: body } };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
void this.tryUnlink(path);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.userRepository.updateUsage(auth.user.id, size);
|
|
||||||
await this.assetRepository.upsertExif({ assetId: assetId, fileSizeInByte: size });
|
|
||||||
await this.jobRepository.queue({
|
|
||||||
name: JobName.AssetExtractMetadata,
|
|
||||||
data: { id: assetId, source: 'upload' },
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Asset created from chunked upload: ${assetId}`);
|
|
||||||
const body = JSON.stringify({ id: assetId, status: AssetMediaStatus.CREATED });
|
|
||||||
return { HTTPResponse: { StatusCode: 201, Body: body } };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async tryUnlink(path: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.storageRepository.unlink(path);
|
|
||||||
} catch {
|
|
||||||
this.logger.warn(`Failed to remove file at ${path}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private requireQuota(auth: AuthDto, size: number) {
|
|
||||||
if (auth.user.quotaSizeInBytes === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) {
|
|
||||||
throw new BadRequestException('Quota has been exceeded!');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseMetadata(encodedAssetData: string): UploadAssetDataDto {
|
|
||||||
let assetData: any;
|
|
||||||
try {
|
|
||||||
assetData = JSON.parse(Buffer.from(encodedAssetData, 'base64').toString('utf8'));
|
|
||||||
} catch {
|
|
||||||
throw new BadRequestException(`Invalid ${ImmichHeader.AssetData} header`);
|
|
||||||
}
|
|
||||||
const dto = plainToInstance(UploadAssetDataDto, assetData);
|
|
||||||
const assetDataErrors = validateSync(dto, { whitelist: true });
|
|
||||||
if (assetDataErrors.length > 0 || !mimeTypes.isAsset(assetData.filename)) {
|
|
||||||
throw new BadRequestException(`Invalid ${ImmichHeader.AssetData} header`);
|
|
||||||
}
|
|
||||||
return dto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import { AlbumService } from 'src/services/album.service';
|
|||||||
import { ApiKeyService } from 'src/services/api-key.service';
|
import { ApiKeyService } from 'src/services/api-key.service';
|
||||||
import { ApiService } from 'src/services/api.service';
|
import { ApiService } from 'src/services/api.service';
|
||||||
import { AssetMediaService } from 'src/services/asset-media.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 { AssetService } from 'src/services/asset.service';
|
||||||
import { AuditService } from 'src/services/audit.service';
|
import { AuditService } from 'src/services/audit.service';
|
||||||
import { AuthAdminService } from 'src/services/auth-admin.service';
|
import { AuthAdminService } from 'src/services/auth-admin.service';
|
||||||
@@ -48,7 +47,6 @@ export const services = [
|
|||||||
AlbumService,
|
AlbumService,
|
||||||
ApiService,
|
ApiService,
|
||||||
AssetMediaService,
|
AssetMediaService,
|
||||||
AssetUploadService,
|
|
||||||
AssetService,
|
AssetService,
|
||||||
AuditService,
|
AuditService,
|
||||||
AuthService,
|
AuthService,
|
||||||
|
|||||||
Reference in New Issue
Block a user