Compare commits

...

11 Commits

Author SHA1 Message Date
shenlong-tanwen
d065059ca1 feat: show notification and battery optimization warning 2026-03-01 10:17:32 +05:30
Hao Xi
aa87d1b9a3 fix: tune up the performance of the getByDayOfYear query. (#26495) 2026-02-27 16:51:19 -05:00
Savely Krasovsky
dc4da4b3d6 feat: update onnxruntime-openvino to 1.24.1 and intel drivers (#26565)
feat: update onnxruntime-openvino to 1.24.1 and intel drivers to the latest version
2026-02-27 16:35:29 -05:00
Marius
7dbd08a747 feat(mobile): add confirmation dialog to permanent delete action (#26442) 2026-02-27 15:49:57 +00:00
Thomas
1d89190f96 fix(mobile): don't cut off top corners of app bar (#26550)
It's not visible normally, but in screenshots and when casting, the top
corners of the app bar are cut off. This should fix that.
2026-02-27 17:39:58 +05:30
Thomas
c2d8400899 fix(mobile): prevent video player from being recreated unnecessarily (#26553)
The changes in #25952 inadvertently removed an optimisation which
prevents the video player from being recreated when the tree changed.
This happens surprisingly often, namely when the hero animation
finishes. The widget is particularly expensive, so recreating it 2-3 in
a short period not only feels sluggish, but also causes the video to
hitch and restart.

The solution is to bring the global key back for the native video
player. Unlike before, we are using a custom global key which compares
the values of hero tags directly. This means we don't need to maintain a
map of hero tags to global keys in the state, and also means we don't
have to pass the global key down multiple layers.

This also fixes #25981.
2026-02-27 17:39:38 +05:30
Mees Frensel
a100a4025e fix(web): handle delete shortcut on shared link page as remove (#26552) 2026-02-27 12:50:06 +01:00
Nikhil Alapati
334fc250d3 fix(server): Live Photo migration bug when album is in template (#25329)
Co-authored-by: Nikhil Alapati <nikhilalapati@meta.com>
2026-02-27 12:46:55 +01:00
Michel Heusschen
28ca5f59fe fix(web): map timeline asset count (#26564) 2026-02-27 12:28:53 +01:00
Thomas
789d82632a fix(mobile): race condition showing details (#26559)
Asset details are prematurely hidden when a drag ends if the simulation
shows that it will close given its current velocity. It makes for a much
more responsible feeling UI. However, this behaviour conflicts with the
logic which determines whether details are showing based on the current
offset. The result is that the details are hidden, then immediately
shown again, and then hidden once it passes the min snap distance
threshold.

This can be fixed by only evaluating the position based logic when a
drag is active, and then inferring upcoming state with a simulation.
2026-02-27 12:12:24 +05:30
Daniel Dietzler
9f9569c152 fix: schema check (#26543) 2026-02-26 13:27:50 -05:00
34 changed files with 695 additions and 110 deletions

View File

@@ -689,6 +689,7 @@
"backup_settings_subtitle": "Manage upload settings",
"backup_upload_details_page_more_details": "Tap for more details",
"backward": "Backward",
"battery_optimization_backup_reliability": "Disabling battery optimizations can improve the reliability of background backup",
"biometric_auth_enabled": "Biometric authentication enabled",
"biometric_locked_out": "You are locked out of biometric authentication",
"biometric_no_options": "No biometric options available",
@@ -1623,6 +1624,7 @@
"not_selected": "Not selected",
"notes": "Notes",
"nothing_here_yet": "Nothing here yet",
"notification_backup_reliability": "Enable notifications to improve background backup reliability",
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
"notification_permission_list_tile_content": "Grant permission to enable notifications.",
"notification_permission_list_tile_enable_button": "Enable Notifications",

View File

@@ -0,0 +1 @@
3.13

View File

@@ -48,14 +48,14 @@ FROM python:3.13-slim-trixie@sha256:3de9a8d7aedbb7984dc18f2dff178a7850f16c1ae7c3
RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.27.10/intel-igc-core-2_2.27.10+20617_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.27.10/intel-igc-opencl-2_2.27.10+20617_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.01.36711.4/intel-opencl-icd_26.01.36711.4-0_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-core-2_2.28.4+20760_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-opencl-2_2.28.4+20760_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/intel-opencl-icd_26.05.37020.3-0_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \
# TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file
wget -nv https://github.com/intel/compute-runtime/releases/download/26.01.36711.4/libigdgmm12_22.9.0_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/libigdgmm12_22.9.0_amd64.deb && \
dpkg -i *.deb && \
rm *.deb && \
apt-get remove wget -yqq && \

View File

@@ -49,7 +49,7 @@ dev = ["locust>=2.15.1", { include-group = "test" }, { include-group = "lint" }]
[project.optional-dependencies]
cpu = ["onnxruntime>=1.23.2,<2"]
cuda = ["onnxruntime-gpu>=1.23.2,<2"]
openvino = ["onnxruntime-openvino>=1.23.0,<2"]
openvino = ["onnxruntime-openvino>=1.24.1,<2"]
armnn = ["onnxruntime>=1.23.2,<2"]
rknn = ["onnxruntime>=1.23.2,<2", "rknn-toolkit-lite2>=2.3.0,<3"]
rocm = ["onnxruntime-migraphx>=1.23.2,<2"]

View File

@@ -262,18 +262,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coloredlogs"
version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "humanfriendly" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" },
]
[[package]]
name = "colorlog"
version = "6.9.0"
@@ -886,18 +874,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl", hash = "sha256:48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270", size = 566395, upload-time = "2026-02-06T09:24:11.133Z" },
]
[[package]]
name = "humanfriendly"
version = "10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyreadline3", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" },
]
[[package]]
name = "idna"
version = "3.11"
@@ -1017,7 +993,7 @@ requires-dist = [
{ name = "onnxruntime", marker = "extra == 'rknn'", specifier = ">=1.23.2,<2" },
{ name = "onnxruntime-gpu", marker = "extra == 'cuda'", specifier = ">=1.23.2,<2" },
{ name = "onnxruntime-migraphx", marker = "extra == 'rocm'", specifier = ">=1.23.2,<2" },
{ name = "onnxruntime-openvino", marker = "extra == 'openvino'", specifier = ">=1.23.0,<2" },
{ name = "onnxruntime-openvino", marker = "extra == 'openvino'", specifier = ">=1.24.1,<2" },
{ name = "opencv-python-headless", specifier = ">=4.7.0.72,<5.0" },
{ name = "orjson", specifier = ">=3.9.5" },
{ name = "pillow", specifier = ">=12.1.1,<12.2" },
@@ -1748,10 +1724,9 @@ wheels = [
[[package]]
name = "onnxruntime-openvino"
version = "1.23.0"
version = "1.24.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coloredlogs" },
{ name = "flatbuffers" },
{ name = "numpy" },
{ name = "packaging" },
@@ -1759,12 +1734,12 @@ dependencies = [
{ name = "sympy" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/10/adcd4ac68ffc8dee003553125ef5c091be822e2d7c1077d0bb85690baa9c/onnxruntime_openvino-1.23.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:91938837e6e92e30c63d12fad68a8a4959c40d2eade2bd60f38bdd5b6392f8d3", size = 70481480, upload-time = "2025-10-14T15:19:45.882Z" },
{ url = "https://files.pythonhosted.org/packages/97/95/25f28d6fecf300aa0af393e96af9e00cc676e5dab650ab84f2122610df50/onnxruntime_openvino-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:8f05d2d6a804fb70d3f4329d777ac62439773dcc2df827dd5f42644b10bf1fea", size = 13117353, upload-time = "2025-10-14T15:19:49.014Z" },
{ url = "https://files.pythonhosted.org/packages/42/0c/8d97419dfeedf419c5fe5293f3dbc59284855a63ad22e71f46c0010c9dc4/onnxruntime_openvino-1.23.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b963ea19bf9856f3d6b2f719d451f2eeae482a8f69c729906465aa4f27f4d39c", size = 70483359, upload-time = "2025-10-14T15:19:52.88Z" },
{ url = "https://files.pythonhosted.org/packages/29/30/ff6111b16ffb4187c462824aa4e95acc20fdd90f856d44a339d56c6dacd6/onnxruntime_openvino-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:937e52657f94c56990a6e5bd4c3705bd6e970834c7c94e23d300dde6848f2889", size = 13117933, upload-time = "2025-10-14T15:19:58.319Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/e42f618a8ec5fcf825fed4fdc8125f7105256cc6020b84567ecb88d5e2b7/onnxruntime_openvino-1.23.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:2e93b9a8323e196b7433866054a59260f2206ab6fb0e7223dda91da71f1db8c5", size = 70483088, upload-time = "2025-10-14T15:20:02.425Z" },
{ url = "https://files.pythonhosted.org/packages/4a/f9/a531dc497dc113dc14df9a9de5aacb1676cadebc3ec6cc7cd3ca65cb3db0/onnxruntime_openvino-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:0ebbf70929de4ce269371cb255536bbedef588932d744da0b40e66c38a620f35", size = 13118206, upload-time = "2025-10-14T15:20:05.587Z" },
{ url = "https://files.pythonhosted.org/packages/99/16/69ca742f0b65c40d4de3ff44bb6abc23c47b23e932bc901116176ae69922/onnxruntime_openvino-1.24.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:3007c803634cc69c6d52af1dea7ce729d9bb62b9a11070fd2f959119199007a8", size = 84430935, upload-time = "2026-02-26T13:44:32.193Z" },
{ url = "https://files.pythonhosted.org/packages/aa/73/619bb416bbfc40aebdd493fd6800d2637359294fe683d8a6bae3ff8d869a/onnxruntime_openvino-1.24.1-cp311-cp311-win_amd64.whl", hash = "sha256:8042698232bf67f1f6b219c2b07728d7ae7ddff17d8524588de3675480609aef", size = 13655357, upload-time = "2026-02-26T13:44:35.555Z" },
{ url = "https://files.pythonhosted.org/packages/50/cf/17ba72de2df0fcba349937d2788f154397bbc2d1a2d67772a97e26f6bc5f/onnxruntime_openvino-1.24.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d617fac2f59a6ab5ea59a788c3e1592240a129642519aaeaa774761dfe35150e", size = 84433207, upload-time = "2026-02-26T13:44:41.395Z" },
{ url = "https://files.pythonhosted.org/packages/59/37/d301f2c68b19a9485ed5db3047e0fb52478f3e73eb08c7d2a7c61be7cc1c/onnxruntime_openvino-1.24.1-cp312-cp312-win_amd64.whl", hash = "sha256:f186335a9c9b255633275290da7521d3d4d14c7773fee3127bfa040234d3fa5a", size = 13658075, upload-time = "2026-02-26T13:44:44.905Z" },
{ url = "https://files.pythonhosted.org/packages/08/07/f225999919f56506b603aaa3ff837ad563ab26f86906ed7fa7e5abcd849e/onnxruntime_openvino-1.24.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:2c3bb73e68ac27f4891af8a595c1faf574ec68b772e6583c90a0b997a1822782", size = 84433183, upload-time = "2026-02-26T13:44:50.254Z" },
{ url = "https://files.pythonhosted.org/packages/3e/92/46ae2cd565961a89189900f385bb2f13a9fa731ea4674001d23720fbb1e0/onnxruntime_openvino-1.24.1-cp313-cp313-win_amd64.whl", hash = "sha256:434bf49aa71393c577a456c9d76c98e6d6958a833fa0876793e3d5437b5a511a", size = 13658485, upload-time = "2026-02-26T13:44:53.889Z" },
]
[[package]]
@@ -2204,15 +2179,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/39/92/8486ede85fcc088f1b3dba4ce92dd29d126fd96b0008ea213167940a2475/pyparsing-3.1.1-py3-none-any.whl", hash = "sha256:32c7c0b711493c72ff18a981d24f28aaf9c1fb7ed5e9667c9e84e3db623bdbfb", size = 103139, upload-time = "2023-07-30T15:06:59.829Z" },
]
[[package]]
name = "pyreadline3"
version = "3.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/86/3d61a61f36a0067874a00cb4dceb9028d34b6060e47828f7fc86fb9f7ee9/pyreadline3-3.4.1.tar.gz", hash = "sha256:6f3d1f7b8a31ba32b73917cefc1f28cc660562f39aea8646d30bd6eff21f7bae", size = 86465, upload-time = "2022-01-24T20:05:11.66Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/fc/a3c13ded7b3057680c8ae95a9b6cc83e63657c38e0005c400a5d018a33a7/pyreadline3-3.4.1-py3-none-any.whl", hash = "sha256:b0efb6516fd4fb07b45949053826a62fa4cb353db5be2bbb4a7aa1fdd1e345fb", size = 95203, upload-time = "2022-01-24T20:05:10.442Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"

View File

@@ -16,6 +16,8 @@ import app.alextran.immich.images.LocalImageApi
import app.alextran.immich.images.LocalImagesImpl
import app.alextran.immich.images.RemoteImageApi
import app.alextran.immich.images.RemoteImagesImpl
import app.alextran.immich.permission.PermissionApi
import app.alextran.immich.permission.PermissionApiImpl
import app.alextran.immich.sync.NativeSyncApi
import app.alextran.immich.sync.NativeSyncApiImpl26
import app.alextran.immich.sync.NativeSyncApiImpl30
@@ -48,6 +50,7 @@ class MainActivity : FlutterFragmentActivity() {
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
PermissionApi.setUp(messenger, PermissionApiImpl(ctx))
flutterEngine.plugins.add(BackgroundServicePlugin())
flutterEngine.plugins.add(backgroundEngineLockImpl)

View File

@@ -0,0 +1,114 @@
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
package app.alextran.immich.permission
import android.util.Log
import io.flutter.plugin.common.BasicMessageChannel
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MessageCodec
import io.flutter.plugin.common.StandardMethodCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private object PermissionApiPigeonUtils {
fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
}
fun wrapError(exception: Throwable): List<Any?> {
return if (exception is FlutterError) {
listOf(
exception.code,
exception.message,
exception.details
)
} else {
listOf(
exception.javaClass.simpleName,
exception.toString(),
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
)
}
}
}
/**
* Error class for passing custom error details to Flutter via a thrown PlatformException.
* @property code The error code.
* @property message The error message.
* @property details The error details. Must be a datatype supported by the api codec.
*/
class FlutterError (
val code: String,
override val message: String? = null,
val details: Any? = null
) : Throwable()
enum class PermissionStatus(val raw: Int) {
GRANTED(0),
DENIED(1),
PERMANENTLY_DENIED(2);
companion object {
fun ofRaw(raw: Int): PermissionStatus? {
return values().firstOrNull { it.raw == raw }
}
}
}
private open class PermissionApiPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
129.toByte() -> {
return (readValue(buffer) as Long?)?.let {
PermissionStatus.ofRaw(it.toInt())
}
}
else -> super.readValueOfType(type, buffer)
}
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) {
is PermissionStatus -> {
stream.write(129)
writeValue(stream, value.raw)
}
else -> super.writeValue(stream, value)
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface PermissionApi {
fun isIgnoringBatteryOptimizations(): PermissionStatus
companion object {
/** The codec used by PermissionApi. */
val codec: MessageCodec<Any?> by lazy {
PermissionApiPigeonCodec()
}
/** Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.isIgnoringBatteryOptimizations$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.isIgnoringBatteryOptimizations())
} catch (exception: Throwable) {
PermissionApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}

View File

@@ -0,0 +1,19 @@
package app.alextran.immich.permission
import android.content.Context
import android.os.PowerManager
class PermissionApiImpl(context: Context) : PermissionApi {
private val ctx: Context = context.applicationContext
private val powerManager =
ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
override fun isIgnoringBatteryOptimizations(): PermissionStatus {
if (powerManager.isIgnoringBatteryOptimizations(ctx.packageName)) {
return PermissionStatus.GRANTED
}
return PermissionStatus.DENIED
}
}

View File

@@ -8,18 +8,25 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/platform/permission_api.g.dart';
import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
import 'package:immich_mobile/providers/notification_permission.provider.dart';
import 'package:immich_mobile/providers/sync_status.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart' as pm;
import 'package:url_launcher/url_launcher.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@RoutePage()
@@ -161,11 +168,7 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
),
),
},
TextButton.icon(
icon: const Icon(Icons.info_outline_rounded),
onPressed: () => context.pushRoute(const DriftUploadDetailRoute()),
label: Text("view_details".t(context: context)),
),
const _BackupFooter(),
],
],
),
@@ -176,6 +179,130 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
}
}
class _BackupFooter extends ConsumerStatefulWidget {
const _BackupFooter();
@override
ConsumerState<_BackupFooter> createState() => _BackupFooterState();
}
class _BackupFooterState extends ConsumerState<_BackupFooter> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (CurrentPlatform.isAndroid && state == AppLifecycleState.resumed && mounted) {
unawaited(ref.read(notificationPermissionProvider.notifier).getNotificationPermission());
unawaited(ref.read(_batteryOptimizationProvider.notifier).getBatteryOptimizationPermission());
}
}
void showPermissionsDialog() {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
content: Text(context.t.notification_permission_dialog_content),
actions: [
TextButton(child: Text(context.t.cancel), onPressed: () => ctx.pop()),
TextButton(
onPressed: () {
context.pop();
pm.openAppSettings();
},
child: Text(context.t.settings),
),
],
),
);
}
void showBatteryOptimizationInfo() {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext ctx) {
return AlertDialog(
title: Text(context.t.backup_controller_page_background_battery_info_title),
content: SingleChildScrollView(child: Text(context.t.backup_controller_page_background_battery_info_message)),
actions: [
ElevatedButton(
onPressed: () => launchUrl(Uri.parse('https://dontkillmyapp.com'), mode: LaunchMode.externalApplication),
child: Text(
context.t.backup_controller_page_background_battery_info_link,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
),
ElevatedButton(
child: Text(
context.t.backup_controller_page_background_battery_info_ok,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
onPressed: () => ctx.pop(),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
final isBackupEnabled = ref.watch(_backupStatusProvider).valueOrNull ?? false;
final notificationStatus = ref.watch(notificationPermissionProvider);
final batteryOptimizationStatus = ref.watch(_batteryOptimizationProvider).valueOrNull;
return Column(
children: [
if (CurrentPlatform.isAndroid && isBackupEnabled) ...[
if (notificationStatus != pm.PermissionStatus.granted)
TextButton.icon(
icon: Icon(Icons.open_in_new_outlined, color: context.colorScheme.onSurfaceSecondary),
label: Text(
context.t.notification_backup_reliability,
textAlign: TextAlign.center,
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
onPressed: () {
ref.read(notificationPermissionProvider.notifier).requestNotificationPermission().then((p) {
if (p == pm.PermissionStatus.permanentlyDenied) {
showPermissionsDialog();
}
});
},
),
// Show only after notification permission is granted
if (notificationStatus == pm.PermissionStatus.granted &&
batteryOptimizationStatus != pm.PermissionStatus.granted)
TextButton.icon(
icon: Icon(Icons.open_in_new_outlined, color: context.colorScheme.onSurfaceSecondary),
label: Text(
context.t.battery_optimization_backup_reliability,
textAlign: TextAlign.center,
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
onPressed: showBatteryOptimizationInfo,
),
],
TextButton.icon(
icon: const Icon(Icons.info_outline_rounded),
onPressed: () => context.pushRoute(const DriftUploadDetailRoute()),
label: Text(context.t.view_details),
),
],
);
}
}
class _BackupAlbumSelectionCard extends ConsumerWidget {
const _BackupAlbumSelectionCard();
@@ -521,3 +648,28 @@ class _PreparingStatusState extends ConsumerState {
);
}
}
final _backupStatusProvider = StreamProvider.autoDispose<bool?>((ref) async* {
yield* ref.watch(storeServiceProvider).watch(StoreKey.enableBackup);
});
final _batteryOptimizationProvider = AsyncNotifierProvider<_BatteryOptimizationNotifier, pm.PermissionStatus>(
_BatteryOptimizationNotifier.new,
);
class _BatteryOptimizationNotifier extends AsyncNotifier<pm.PermissionStatus> {
Future<pm.PermissionStatus> getBatteryOptimizationPermission() async {
final pm.PermissionStatus status;
final isIgnoring = await ref.read(permissionApiProvider).isIgnoringBatteryOptimizations();
if (isIgnoring == PermissionStatus.granted) {
status = pm.PermissionStatus.granted;
} else {
status = pm.PermissionStatus.denied;
}
state = AsyncValue.data(status);
return status;
}
@override
FutureOr<pm.PermissionStatus> build() => getBatteryOptimizationPermission();
}

View File

@@ -0,0 +1,87 @@
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
import 'dart:async';
import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
import 'package:flutter/services.dart';
PlatformException _createConnectionError(String channelName) {
return PlatformException(
code: 'channel-error',
message: 'Unable to establish connection on channel: "$channelName".',
);
}
enum PermissionStatus { granted, denied, permanentlyDenied }
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
void writeValue(WriteBuffer buffer, Object? value) {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else if (value is PermissionStatus) {
buffer.putUint8(129);
writeValue(buffer, value.index);
} else {
super.writeValue(buffer, value);
}
}
@override
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
case 129:
final int? value = readValue(buffer) as int?;
return value == null ? null : PermissionStatus.values[value];
default:
return super.readValueOfType(type, buffer);
}
}
}
class PermissionApi {
/// Constructor for [PermissionApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
PermissionApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
Future<PermissionStatus> isIgnoringBatteryOptimizations() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.PermissionApi.isIgnoringBatteryOptimizations$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else if (pigeonVar_replyList[0] == null) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as PermissionStatus?)!;
}
}
}

View File

@@ -8,6 +8,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/permanent_delete_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
/// This delete action has the following behavior:
@@ -25,6 +26,15 @@ class DeletePermanentActionButton extends ConsumerWidget {
return;
}
final count = source == ActionSource.viewer ? 1 : ref.read(multiSelectProvider).selectedAssets.length;
final confirm =
await showDialog<bool>(
context: context,
builder: (context) => PermanentDeleteDialog(count: count),
) ??
false;
if (!confirm) return;
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
ref.read(multiSelectProvider.notifier).reset();

View File

@@ -5,7 +5,7 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/trash_delete_dialog.dart';
import 'package:immich_mobile/widgets/asset_grid/permanent_delete_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
/// This delete action has the following behavior:
@@ -28,7 +28,7 @@ class DeleteTrashActionButton extends ConsumerWidget {
final confirmDelete =
await showDialog<bool>(
context: context,
builder: (context) => TrashDeleteDialog(count: selectCount),
builder: (context) => PermanentDeleteDialog(count: selectCount),
) ??
false;
if (!confirmDelete) {

View File

@@ -64,7 +64,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
@override
void initState() {
super.initState();
_proxyScrollController.addListener(_onScroll);
_eventSubscription = EventStream.shared.listen(_onEvent);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_proxyScrollController.hasClients) return;
@@ -94,6 +93,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
void _showDetails() {
if (!_proxyScrollController.hasClients || _snapOffset <= 0) return;
_viewer.setShowingDetails(true);
_proxyScrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic);
}
@@ -105,7 +105,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
SnapScrollPhysics.target(position, scrollVelocity, _snapOffset) < SnapScrollPhysics.minSnapDistance;
}
void _onScroll() {
void _syncShowingDetails() {
final offset = _proxyScrollController.offset;
if (offset > SnapScrollPhysics.minSnapDistance) {
_viewer.setShowingDetails(true);
@@ -149,6 +149,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
case _DragIntent.scroll:
if (_drag == null) _startProxyDrag();
_drag?.update(details);
_syncShowingDetails();
case _DragIntent.dismiss:
_handleDragDown(context, details.localPosition - _dragStart!.localPosition);
}
@@ -167,9 +169,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
case _DragIntent.none:
case _DragIntent.scroll:
final scrollVelocity = -(details.primaryVelocity ?? 0.0);
if (_willClose(scrollVelocity)) {
_viewer.setShowingDetails(false);
}
_viewer.setShowingDetails(!_willClose(scrollVelocity));
_drag?.end(details);
_drag = null;
case _DragIntent.dismiss:
@@ -306,7 +307,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
if (displayAsset.isImage && !isPlayingMotionVideo) {
final size = context.sizeData;
return PhotoView(
key: ValueKey(displayAsset.heroTag),
key: Key(displayAsset.heroTag),
index: widget.index,
imageProvider: getFullImageProvider(displayAsset, size: size),
heroAttributes: heroAttributes,
@@ -334,7 +335,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
}
return PhotoView.customChild(
key: ValueKey(displayAsset),
key: Key(displayAsset.heroTag),
onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd,
@@ -350,12 +351,11 @@ class _AssetPageState extends ConsumerState<AssetPage> {
enablePanAlways: true,
backgroundDecoration: backgroundDecoration,
child: NativeVideoViewer(
key: ValueKey(displayAsset),
key: _NativeVideoViewerKey(displayAsset.heroTag),
asset: displayAsset,
scaleStateNotifier: _videoScaleStateNotifier,
disableScaleGestures: showingDetails,
image: Image(
key: ValueKey(displayAsset.heroTag),
image: getFullImageProvider(displayAsset, size: context.sizeData),
height: context.height,
width: context.width,
@@ -459,3 +459,25 @@ class _AssetPageState extends ConsumerState<AssetPage> {
);
}
}
// A global key is used for video viewers to prevent them from being
// unnecessarily recreated. They're quite expensive, and maintain internal
// state. This can cause videos to restart multiple times during normal usage,
// like a hero animation.
//
// A plain ValueKey is insufficient, as it does not allow widgets to reparent. A
// GlobalObjectKey is fragile, as it checks if the given objects are identical,
// rather than equal. Hero tags are created with string interpolation, which
// prevents Dart from interning them. As such, hero tags are not identical, even
// if they are equal.
class _NativeVideoViewerKey extends GlobalKey {
final String value;
const _NativeVideoViewerKey(this.value) : super.constructor();
@override
bool operator ==(Object other) => other is _NativeVideoViewerKey && other.value == value;
@override
int get hashCode => value.hashCode;
}

View File

@@ -420,20 +420,18 @@ class NativeVideoViewer extends HookConsumerWidget {
child: Stack(
children: [
// Hide thumbnail once video is visible to avoid it showing in background when zooming out on video.
if (!isVisible.value || controller.value == null) Center(key: ValueKey(asset.heroTag), child: image),
if (!isVisible.value || controller.value == null) Center(child: image),
if (aspectRatio.value != null && !isCasting && isCurrent)
Visibility.maintain(
key: ValueKey(asset),
visible: isVisible.value,
child: PhotoView.customChild(
key: ValueKey(asset),
enableRotation: false,
disableScaleGestures: disableScaleGestures,
// Transparent to avoid a black flash when viewer becomes visible but video isn't loaded yet.
backgroundDecoration: const BoxDecoration(color: Colors.transparent),
scaleStateChangedCallback: (state) => scaleStateNotifier?.value = state,
childSize: videoContextSize(aspectRatio.value, context),
child: NativeVideoPlayerView(key: ValueKey(asset), onViewReady: initController),
child: NativeVideoPlayerView(onViewReady: initController),
),
),
if (showControls) const Center(child: VideoViewerControls()),

View File

@@ -3,9 +3,10 @@ import 'package:immich_mobile/domain/services/background_worker.service.dart';
import 'package:immich_mobile/platform/background_worker_api.g.dart';
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
import 'package:immich_mobile/platform/connectivity_api.g.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/platform/local_image_api.g.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/platform/network_api.g.dart';
import 'package:immich_mobile/platform/permission_api.g.dart';
import 'package:immich_mobile/platform/remote_image_api.g.dart';
final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
@@ -18,6 +19,8 @@ final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
final connectivityApiProvider = Provider<ConnectivityApi>((_) => ConnectivityApi());
final permissionApiProvider = Provider<PermissionApi>((_) => PermissionApi());
final localImageApi = LocalImageApi();
final remoteImageApi = RemoteImageApi();

View File

@@ -3,8 +3,8 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_ui/immich_ui.dart';
class TrashDeleteDialog extends StatelessWidget {
const TrashDeleteDialog({super.key, required this.count});
class PermanentDeleteDialog extends StatelessWidget {
const PermanentDeleteDialog({super.key, required this.count});
final int count;

View File

@@ -62,7 +62,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
pinned: pinned,
snap: snap,
expandedHeight: expandedHeight,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(bottom: Radius.circular(5))),
automaticallyImplyLeading: false,
centerTitle: false,
title: title ?? const _ImmichLogoWithText(),

View File

@@ -13,6 +13,7 @@ pigeon:
dart run pigeon --input pigeon/background_worker_lock_api.dart
dart run pigeon --input pigeon/connectivity_api.dart
dart run pigeon --input pigeon/network_api.dart
dart run pigeon --input pigeon/permission_api.dart
dart format lib/platform/native_sync_api.g.dart
dart format lib/platform/local_image_api.g.dart
dart format lib/platform/remote_image_api.g.dart
@@ -20,6 +21,7 @@ pigeon:
dart format lib/platform/background_worker_lock_api.g.dart
dart format lib/platform/connectivity_api.g.dart
dart format lib/platform/network_api.g.dart
dart format lib/platform/permission_api.g.dart
watch:
dart run build_runner watch --delete-conflicting-outputs

View File

@@ -40,7 +40,13 @@ depends = [
[tasks."codegen:translation"]
alias = "translation"
description = "Generate translations from i18n JSONs"
run = [{ task = "//:i18n:format-fix" }, { tasks = ["i18n:loader", "i18n:keys"] }]
run = [
{ task = "//:i18n:format-fix" },
{ tasks = [
"i18n:loader",
"i18n:keys",
] },
]
[tasks."codegen:app-icon"]
description = "Generate app icons"
@@ -146,6 +152,19 @@ run = [
"dart format lib/platform/connectivity_api.g.dart",
]
[tasks."pigeon:permission"]
description = "Generate permission API pigeon code"
hide = true
sources = ["pigeon/permission_api.dart"]
outputs = [
"lib/platform/permission_api.g.dart",
"android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt",
]
run = [
"dart run pigeon --input pigeon/permission_api.dart",
"dart format lib/platform/permission_api.g.dart",
]
[tasks."i18n:loader"]
description = "Generate i18n loader"
hide = true

View File

@@ -0,0 +1,17 @@
import 'package:pigeon/pigeon.dart';
enum PermissionStatus { granted, denied, permanentlyDenied }
@ConfigurePigeon(
PigeonOptions(
dartOut: 'lib/platform/permission_api.g.dart',
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt',
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.permission'),
dartOptions: DartOptions(),
dartPackageName: 'immich_mobile',
),
)
@HostApi()
abstract class PermissionApi {
PermissionStatus isIgnoringBatteryOptimizations();
}

View File

@@ -562,6 +562,7 @@ select
"asset"."checksum",
"asset"."originalPath",
"asset"."isExternal",
"asset"."visibility",
"asset"."originalFileName",
"asset"."livePhotoVideoId",
"asset"."fileCreatedAt",
@@ -593,6 +594,7 @@ from
where
"asset"."deletedAt" is null
and "asset"."id" = $2
and "asset"."visibility" != $3
-- AssetJobRepository.streamForStorageTemplateJob
select
@@ -602,6 +604,7 @@ select
"asset"."checksum",
"asset"."originalPath",
"asset"."isExternal",
"asset"."visibility",
"asset"."originalFileName",
"asset"."livePhotoVideoId",
"asset"."fileCreatedAt",
@@ -632,6 +635,7 @@ from
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."deletedAt" is null
and "asset"."visibility" != $2
-- AssetJobRepository.streamForDeletedJob
select

View File

@@ -123,13 +123,13 @@ with
) as "year"
)
select
"a".*,
to_json("asset_exif") as "exifInfo"
"a".*
from
"today"
inner join lateral (
select
"asset".*
"asset"."id",
"asset"."localDateTime"
from
"asset"
inner join "asset_job_status" on "asset"."id" = "asset_job_status"."assetId"
@@ -151,7 +151,6 @@ with
limit
$7
) as "a" on true
inner join "asset_exif" on "a"."id" = "asset_exif"."assetId"
)
select
date_part(

View File

@@ -353,6 +353,7 @@ export class AssetJobRepository {
'asset.checksum',
'asset.originalPath',
'asset.isExternal',
'asset.visibility',
'asset.originalFileName',
'asset.livePhotoVideoId',
'asset.fileCreatedAt',
@@ -367,13 +368,16 @@ export class AssetJobRepository {
}
@GenerateSql({ params: [DummyValue.UUID] })
getForStorageTemplateJob(id: string) {
return this.storageTemplateAssetQuery().where('asset.id', '=', id).executeTakeFirst();
getForStorageTemplateJob(id: string, options?: { includeHidden?: boolean }) {
return this.storageTemplateAssetQuery()
.where('asset.id', '=', id)
.$if(!options?.includeHidden, (qb) => qb.where('asset.visibility', '!=', AssetVisibility.Hidden))
.executeTakeFirst();
}
@GenerateSql({ params: [], stream: true })
streamForStorageTemplateJob() {
return this.storageTemplateAssetQuery().stream();
return this.storageTemplateAssetQuery().where('asset.visibility', '!=', AssetVisibility.Hidden).stream();
}
@GenerateSql({ params: [DummyValue.DATE], stream: true })

View File

@@ -404,7 +404,7 @@ export class AssetRepository {
(qb) =>
qb
.selectFrom('asset')
.selectAll('asset')
.select(['asset.id', 'asset.localDateTime'])
.innerJoin('asset_job_status', 'asset.id', 'asset_job_status.assetId')
.where(sql`(asset."localDateTime" at time zone 'UTC')::date`, '=', sql`today.date`)
.where('asset.ownerId', '=', anyUuid(ownerIds))
@@ -423,9 +423,7 @@ export class AssetRepository {
.as('a'),
(join) => join.onTrue(),
)
.innerJoin('asset_exif', 'a.id', 'asset_exif.assetId')
.selectAll('a')
.select((eb) => eb.fn.toJson(eb.table('asset_exif')).as('exifInfo')),
.selectAll('a'),
)
.selectFrom('res')
.select(sql<number>`date_part('year', ("localDateTime" at time zone 'UTC')::date)::int`.as('year'))

View File

@@ -22,6 +22,7 @@ import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import 'src/schema'; // make sure all schema definitions are imported for schemaFromCode
import { DB } from 'src/schema';
import { immich_uuid_v7 } from 'src/schema/functions';
import { ExtensionVersion, VectorExtension, VectorUpdateResult } from 'src/types';
import { vectorIndexQuery } from 'src/utils/database';
import { isValidInteger } from 'src/validation';
@@ -288,7 +289,11 @@ export class DatabaseRepository {
}
async getSchemaDrift() {
const source = schemaFromCode({ overrides: true, namingStrategy: 'default' });
const source = schemaFromCode({
overrides: true,
namingStrategy: 'default',
uuidFunction: (version) => (version === 7 ? `${immich_uuid_v7.name}()` : 'uuid_generate_v4()'),
});
const { database } = this.configRepository.getEnv();
const target = await schemaFromDatabase({ connection: database.config });

View File

@@ -280,7 +280,7 @@ export const asset_edit_delete = registerFunction({
UPDATE asset
SET "isEdited" = false
FROM deleted_edit
WHERE asset.id = deleted_edit."assetId" AND asset."isEdited"
WHERE asset.id = deleted_edit."assetId" AND asset."isEdited"
AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit."assetId" = asset.id);
RETURN NULL;
END

View File

@@ -0,0 +1,36 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION asset_edit_delete()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
UPDATE asset
SET "isEdited" = false
FROM deleted_edit
WHERE asset.id = deleted_edit."assetId" AND asset."isEdited"
AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit."assetId" = asset.id);
RETURN NULL;
END
$$;`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"asset_edit_delete","sql":"CREATE OR REPLACE FUNCTION asset_edit_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE asset\\n SET \\"isEdited\\" = false\\n FROM deleted_edit\\n WHERE asset.id = deleted_edit.\\"assetId\\" AND asset.\\"isEdited\\"\\n AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit.\\"assetId\\" = asset.id);\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_asset_edit_delete';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION public.asset_edit_delete()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
UPDATE asset
SET "isEdited" = false
FROM deleted_edit
WHERE asset.id = deleted_edit."assetId" AND asset."isEdited"
AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit."assetId" = asset.id);
RETURN NULL;
END
$function$
`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE OR REPLACE FUNCTION asset_edit_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE asset\\n SET \\"isEdited\\" = false\\n FROM deleted_edit\\n WHERE asset.id = deleted_edit.\\"assetId\\" AND asset.\\"isEdited\\" \\n AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit.\\"assetId\\" = asset.id);\\n RETURN NULL;\\n END\\n $$;","name":"asset_edit_delete","type":"function"}'::jsonb WHERE "name" = 'function_asset_edit_delete';`.execute(db);
}

View File

@@ -9,6 +9,9 @@ import { userStub } from 'test/fixtures/user.stub';
import { getForStorageTemplate } from 'test/mappers';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
const motionAsset = AssetFactory.from({ type: AssetType.Video }).exif().build();
const stillAsset = AssetFactory.from({ livePhotoVideoId: motionAsset.id }).exif().build();
describe(StorageTemplateService.name, () => {
let sut: StorageTemplateService;
let mocks: ServiceMocks;
@@ -153,6 +156,58 @@ describe(StorageTemplateService.name, () => {
expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, originalPath: newMotionPicturePath });
});
it('should migrate live photo motion video alongside the still image using album in path', async () => {
const motionAsset = AssetFactory.from({
type: AssetType.Video,
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.exif()
.build();
const stillAsset = AssetFactory.from({
livePhotoVideoId: motionAsset.id,
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.exif()
.build();
const album = AlbumFactory.from().asset().build();
const config = structuredClone(defaults);
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}';
sut.onConfigInit({ newConfig: config });
mocks.user.get.mockResolvedValue(userStub.user1);
const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/${album.albumName}/${stillAsset.originalFileName.slice(0, -4)}.mp4`;
const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/${album.albumName}/${stillAsset.originalFileName}`;
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(stillAsset));
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset));
mocks.album.getByAssetId.mockResolvedValue([album]);
mocks.move.create.mockResolvedValueOnce({
id: '123',
entityId: stillAsset.id,
pathType: AssetPathType.Original,
oldPath: stillAsset.originalPath,
newPath: newStillPicturePath,
});
mocks.move.create.mockResolvedValueOnce({
id: '124',
entityId: motionAsset.id,
pathType: AssetPathType.Original,
oldPath: motionAsset.originalPath,
newPath: newMotionPicturePath,
});
await expect(sut.handleMigrationSingle({ id: stillAsset.id })).resolves.toBe(JobStatus.Success);
expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(2);
expect(mocks.album.getByAssetId).toHaveBeenCalledWith(stillAsset.ownerId, stillAsset.id);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: stillAsset.id, originalPath: newStillPicturePath });
expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, originalPath: newMotionPicturePath });
});
it('should use handlebar if condition for album', async () => {
const user = UserFactory.create();
const asset = AssetFactory.from().owner(user).exif().build();
@@ -709,12 +764,18 @@ describe(StorageTemplateService.name, () => {
})
.exif()
.build();
const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName.slice(0, -4)}.mp4`;
const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName}`;
const album = AlbumFactory.from().asset().build();
const config = structuredClone(defaults);
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}';
sut.onConfigInit({ newConfig: config });
const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/${album.albumName}/${stillAsset.originalFileName.slice(0, -4)}.mp4`;
const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/${album.albumName}/${stillAsset.originalFileName}`;
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(stillAsset)]));
mocks.user.getList.mockResolvedValue([userStub.user1]);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset));
mocks.album.getByAssetId.mockResolvedValue([album]);
mocks.move.create.mockResolvedValueOnce({
id: '123',
@@ -735,11 +796,53 @@ describe(StorageTemplateService.name, () => {
await sut.handleMigration();
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.assetJob.getForStorageTemplateJob).toHaveBeenCalledWith(motionAsset.id);
expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(2);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: stillAsset.id, originalPath: newStillPicturePath });
expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, originalPath: newMotionPicturePath });
});
it('should use still photo album info when migrating live photo motion video', async () => {
const user = userStub.user1;
const album = AlbumFactory.from().asset().build();
const config = structuredClone(defaults);
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other{{/if}}/{{filename}}';
sut.onConfigInit({ newConfig: config });
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(stillAsset)]));
mocks.user.getList.mockResolvedValue([user]);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset));
mocks.album.getByAssetId.mockResolvedValue([album]);
mocks.move.create.mockResolvedValueOnce({
id: '123',
entityId: stillAsset.id,
pathType: AssetPathType.Original,
oldPath: stillAsset.originalPath,
newPath: `/data/library/${user.id}/2022/${album.albumName}/${stillAsset.originalFileName}`,
});
mocks.move.create.mockResolvedValueOnce({
id: '124',
entityId: motionAsset.id,
pathType: AssetPathType.Original,
oldPath: motionAsset.originalPath,
newPath: `/data/library/${user.id}/2022/${album.albumName}/${motionAsset.originalFileName}`,
});
await sut.handleMigration();
expect(mocks.album.getByAssetId).toHaveBeenCalledWith(stillAsset.ownerId, stillAsset.id);
expect(mocks.album.getByAssetId).toHaveBeenCalledTimes(2);
expect(mocks.asset.update).toHaveBeenCalledWith({
id: stillAsset.id,
originalPath: expect.stringContaining(`/${album.albumName}/`),
});
expect(mocks.asset.update).toHaveBeenCalledWith({
id: motionAsset.id,
originalPath: expect.stringContaining(`/${album.albumName}/`),
});
});
});
describe('file rename correctness', () => {

View File

@@ -158,12 +158,14 @@ export class StorageTemplateService extends BaseService {
// move motion part of live photo
if (asset.livePhotoVideoId) {
const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId);
const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId, {
includeHidden: true,
});
if (!livePhotoVideo) {
return JobStatus.Failed;
}
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename });
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename }, asset);
}
return JobStatus.Success;
}
@@ -191,10 +193,12 @@ export class StorageTemplateService extends BaseService {
// move motion part of live photo
if (asset.livePhotoVideoId) {
const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId);
const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId, {
includeHidden: true,
});
if (livePhotoVideo) {
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename });
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename }, asset);
}
}
}
@@ -214,7 +218,7 @@ export class StorageTemplateService extends BaseService {
await this.moveRepository.cleanMoveHistorySingle(assetId);
}
async moveAsset(asset: StorageAsset, metadata: MoveAssetMetadata) {
async moveAsset(asset: StorageAsset, metadata: MoveAssetMetadata, stillPhoto?: StorageAsset) {
if (asset.isExternal || StorageCore.isAndroidMotionPath(asset.originalPath)) {
// External assets are not affected by storage template
// TODO: shouldn't this only apply to external assets?
@@ -224,7 +228,7 @@ export class StorageTemplateService extends BaseService {
return this.databaseRepository.withLock(DatabaseLock.StorageTemplateMigration, async () => {
const { id, originalPath, checksum, fileSizeInByte } = asset;
const oldPath = originalPath;
const newPath = await this.getTemplatePath(asset, metadata);
const newPath = await this.getTemplatePath(asset, metadata, stillPhoto);
if (!fileSizeInByte) {
this.logger.error(`Asset ${id} missing exif info, skipping storage template migration`);
@@ -255,7 +259,11 @@ export class StorageTemplateService extends BaseService {
});
}
private async getTemplatePath(asset: StorageAsset, metadata: MoveAssetMetadata): Promise<string> {
private async getTemplatePath(
asset: StorageAsset,
metadata: MoveAssetMetadata,
stillPhoto?: StorageAsset,
): Promise<string> {
const { storageLabel, filename } = metadata;
try {
@@ -296,8 +304,12 @@ export class StorageTemplateService extends BaseService {
let albumName = null;
let albumStartDate = null;
let albumEndDate = null;
const assetForMetadata = stillPhoto || asset;
if (this.template.needsAlbum) {
const albums = await this.albumRepository.getByAssetId(asset.ownerId, asset.id);
// For motion videos, use the still photo's album information since motion videos
// don't have album metadata attached directly
const albums = await this.albumRepository.getByAssetId(assetForMetadata.ownerId, assetForMetadata.id);
const album = albums?.[0];
if (album) {
albumName = album.albumName || null;
@@ -310,16 +322,18 @@ export class StorageTemplateService extends BaseService {
}
}
// For motion videos that are part of live photos, use the still photo's date
// to ensure both parts end up in the same folder
const storagePath = this.render(this.template.compiled, {
asset,
asset: assetForMetadata,
filename: sanitized,
extension,
albumName,
albumStartDate,
albumEndDate,
make: asset.make,
model: asset.model,
lensModel: asset.lensModel,
make: assetForMetadata.make,
model: assetForMetadata.model,
lensModel: assetForMetadata.lensModel,
});
const fullPath = path.normalize(path.join(rootPath, storagePath));
let destination = `${fullPath}.${extension}`;

View File

@@ -12,6 +12,7 @@ export const getForStorageTemplate = (asset: ReturnType<AssetFactory['build']>)
isExternal: asset.isExternal,
checksum: asset.checksum,
timeZone: asset.exifInfo.timeZone,
visibility: asset.visibility,
fileCreatedAt: asset.fileCreatedAt,
originalPath: asset.originalPath,
originalFileName: asset.originalFileName,

View File

@@ -140,7 +140,7 @@
</ControlAppBar>
{/if}
<section class="my-40 mx-4" bind:clientHeight={viewport.height} bind:clientWidth={viewport.width}>
<GalleryViewer {assets} {assetInteraction} {viewport} />
<GalleryViewer {assets} {assetInteraction} {viewport} allowDeletion={false} />
</section>
{:else if assets.length === 1}
{#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset}

View File

@@ -45,6 +45,7 @@
pageHeaderOffset?: number;
slidingWindowOffset?: number;
arrowNavigation?: boolean;
allowDeletion?: boolean;
};
let {
@@ -60,6 +61,7 @@
slidingWindowOffset = 0,
pageHeaderOffset = 0,
arrowNavigation = true,
allowDeletion = true,
}: Props = $props();
let { isViewing: isViewerOpen, asset: viewingAsset } = assetViewingStore;
@@ -273,11 +275,15 @@
if (assetInteraction.selectionActive) {
shortcuts.push(
{ shortcut: { key: 'Escape' }, onShortcut: deselectAllAssets },
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
{ shortcut: { key: 'D', ctrl: true }, onShortcut: deselectAllAssets },
);
if (allowDeletion) {
shortcuts.push(
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
);
}
}
return shortcuts;

View File

@@ -21,7 +21,7 @@
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { locale, mapSettings } from '$lib/stores/preferences.store';
import { mapSettings } from '$lib/stores/preferences.store';
import { preferences, user } from '$lib/stores/user.store';
import {
updateStackedAssetInTimeline,
@@ -90,8 +90,6 @@
assetFilter: selectedClusterIds,
});
const displayedAssetCount = $derived(timelineManager?.assetCount ?? assetCount);
$effect.pre(() => {
void timelineOptions;
assetInteraction.clearMultiselect();
@@ -103,8 +101,7 @@
<div class="flex items-center gap-2">
<Icon icon={mdiImageMultiple} size="20" />
<p class="text-sm font-medium text-immich-fg dark:text-immich-dark-fg">
{displayedAssetCount.toLocaleString($locale)}
{$t('assets')}
{$t('assets_count', { values: { count: assetCount } })}
</p>
</div>
<CloseButton onclick={onClose} />

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import { handleRemoveSharedLinkAssets } from '$lib/services/shared-link.service';
import { getAssetControlContext } from '$lib/utils/context';
import { type SharedLinkResponseDto } from '@immich/sdk';
@@ -23,6 +24,8 @@
};
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'Delete' }, onShortcut: handleSelect }} />
<IconButton
shape="round"
color="secondary"