diff --git a/cli/package-lock.json b/cli/package-lock.json
index d15358b26e..fe428b2714 100644
--- a/cli/package-lock.json
+++ b/cli/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@immich/cli",
- "version": "2.2.63",
+ "version": "2.2.65",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/cli",
- "version": "2.2.63",
+ "version": "2.2.65",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"chokidar": "^4.0.3",
@@ -54,7 +54,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
- "version": "1.132.1",
+ "version": "1.132.3",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
diff --git a/cli/package.json b/cli/package.json
index 8a742cd0d7..b2d29d6bb9 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
- "version": "2.2.63",
+ "version": "2.2.65",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",
diff --git a/docs/docs/guides/custom-map-styles.md b/docs/docs/guides/custom-map-styles.md
index 3f52937432..1a61afc324 100644
--- a/docs/docs/guides/custom-map-styles.md
+++ b/docs/docs/guides/custom-map-styles.md
@@ -14,14 +14,14 @@ online generators you can use.
2. Paste the link to your JSON style in either the **Light Style** or **Dark Style**. (You can add different styles which will help make the map style more appropriate depending on whether you set **Immich** to Light or Dark mode.)
3. Save your selections. Reload the map, and enjoy your custom map style!
-## Use Maptiler to build a custom style
+## Use MapTiler to build a custom style
-Customizing the map style can be done easily using Maptiler, if you do not want to write an entire JSON document by hand.
+Customizing the map style can be done easily using MapTiler, if you do not want to write an entire JSON document by hand.
1. Create a free account at https://cloud.maptiler.com
2. Once logged in, you can either create a brand new map by clicking on **New Map**, selecting a starter map, and then clicking **Customize**, OR by selecting a **Standard Map** and customizing it from there.
3. The **editor** interface is self-explanatory. You can change colors, remove visible layers, or add optional layers (e.g., administrative, topo, hydro, etc.) in the composer.
4. Once you have your map composed, click on **Save** at the top right. Give it a unique name to save it to your account.
-5. Next, **Publish** your style using the **Publish** button at the top right. This will deploy it to production, which means it is able to be exposed over the Internet. Maptiler will present an interactive side-by-side map with the original and your changes prior to publication.

-6. Maptiler will warn you that changing the map will change it across all apps using the map. Since no apps are using the map yet, this is okay.
-7. Clicking on the name of your new map at the top left will bring you to the item's **details** page. From here, copy the link to the JSON style under **Use vector style**. This link will automatically contain your personal API key to Maptiler.
+5. Next, **Publish** your style using the **Publish** button at the top right. This will deploy it to production, which means it is able to be exposed over the Internet. MapTiler will present an interactive side-by-side map with the original and your changes prior to publication.

+6. MapTiler will warn you that changing the map will change it across all apps using the map. Since no apps are using the map yet, this is okay.
+7. Clicking on the name of your new map at the top left will bring you to the item's **details** page. From here, copy the link to the JSON style under **Use vector style**. This link will automatically contain your personal API key to MapTiler.
diff --git a/docs/docs/guides/database-queries.md b/docs/docs/guides/database-queries.md
index 89a4f07bc0..209f673993 100644
--- a/docs/docs/guides/database-queries.md
+++ b/docs/docs/guides/database-queries.md
@@ -1,7 +1,7 @@
# Database Queries
:::danger
-Keep in mind that mucking around in the database might set the moon on fire. Avoid modifying the database directly when possible, and always have current backups.
+Keep in mind that mucking around in the database might set the Moon on fire. Avoid modifying the database directly when possible, and always have current backups.
:::
:::tip
diff --git a/docs/src/pages/roadmap.tsx b/docs/src/pages/roadmap.tsx
index 4dc391cb27..1e0914a651 100644
--- a/docs/src/pages/roadmap.tsx
+++ b/docs/src/pages/roadmap.tsx
@@ -252,6 +252,13 @@ const milestones: Item[] = [
description: 'Browse your photos and videos in their folder structure inside the mobile app',
release: 'v1.130.0',
}),
+ {
+ icon: mdiStar,
+ iconColor: 'gold',
+ title: '60,000 Stars',
+ description: 'Reached 60K Stars on GitHub!',
+ getDateLabel: withLanguage(new Date(2025, 2, 4)),
+ },
withRelease({
icon: mdiTagFaces,
iconColor: 'teal',
@@ -260,13 +267,6 @@ const milestones: Item[] = [
'Manually tag or remove faces in photos and videos, even when automatic detection misses or misidentifies them.',
release: 'v1.127.0',
}),
- {
- icon: mdiStar,
- iconColor: 'gold',
- title: '60,000 Stars',
- description: 'Reached 60K Stars on GitHub!',
- getDateLabel: withLanguage(new Date(2025, 2, 4)),
- },
withRelease({
icon: mdiLinkEdit,
iconColor: 'crimson',
diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json
index 26eb3a2f9a..1e45c7a696 100644
--- a/docs/static/archived-versions.json
+++ b/docs/static/archived-versions.json
@@ -1,4 +1,12 @@
[
+ {
+ "label": "v1.132.3",
+ "url": "https://v1.132.3.archive.immich.app"
+ },
+ {
+ "label": "v1.132.2",
+ "url": "https://v1.132.2.archive.immich.app"
+ },
{
"label": "v1.132.1",
"url": "https://v1.132.1.archive.immich.app"
diff --git a/e2e/package-lock.json b/e2e/package-lock.json
index 7eb831b897..af8117da2c 100644
--- a/e2e/package-lock.json
+++ b/e2e/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "immich-e2e",
- "version": "1.132.1",
+ "version": "1.132.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-e2e",
- "version": "1.132.1",
+ "version": "1.132.3",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
@@ -44,7 +44,7 @@
},
"../cli": {
"name": "@immich/cli",
- "version": "2.2.63",
+ "version": "2.2.65",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
@@ -93,7 +93,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
- "version": "1.132.1",
+ "version": "1.132.3",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
diff --git a/e2e/package.json b/e2e/package.json
index 3946f149d6..c4da9b8a4a 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
- "version": "1.132.1",
+ "version": "1.132.3",
"description": "",
"main": "index.js",
"type": "module",
diff --git a/e2e/src/web/specs/auth.e2e-spec.ts b/e2e/src/web/specs/auth.e2e-spec.ts
index e89f17a4e9..74bee64e0a 100644
--- a/e2e/src/web/specs/auth.e2e-spec.ts
+++ b/e2e/src/web/specs/auth.e2e-spec.ts
@@ -25,7 +25,7 @@ test.describe('Registration', () => {
// login
await expect(page).toHaveTitle(/Login/);
- await page.goto('/auth/login');
+ await page.goto('/auth/login?autoLaunch=0');
await page.getByLabel('Email').fill('admin@immich.app');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Login' }).click();
@@ -59,7 +59,7 @@ test.describe('Registration', () => {
await context.clearCookies();
// login
- await page.goto('/auth/login');
+ await page.goto('/auth/login?autoLaunch=0');
await page.getByLabel('Email').fill('user@immich.cloud');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Login' }).click();
@@ -72,7 +72,7 @@ test.describe('Registration', () => {
await page.getByRole('button', { name: 'Change password' }).click();
// login with new password
- await expect(page).toHaveURL('/auth/login');
+ await expect(page).toHaveURL('/auth/login?autoLaunch=0');
await page.getByLabel('Email').fill('user@immich.cloud');
await page.getByLabel('Password').fill('new-password');
await page.getByRole('button', { name: 'Login' }).click();
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt
index 8520413cff..ae2ec22a71 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt
@@ -1,25 +1,42 @@
package app.alextran.immich
+import android.app.Activity
+import android.content.ContentResolver
+import android.content.ContentUris
import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.provider.MediaStore
+import android.provider.Settings
import android.util.Log
+import androidx.annotation.RequiresApi
import io.flutter.embedding.engine.plugins.FlutterPlugin
+import io.flutter.embedding.engine.plugins.activity.ActivityAware
+import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
+import io.flutter.plugin.common.MethodChannel.Result
+import io.flutter.plugin.common.PluginRegistry
import java.security.MessageDigest
import java.io.FileInputStream
import kotlinx.coroutines.*
+import androidx.core.net.toUri
/**
- * Android plugin for Dart `BackgroundService`
- *
- * Receives messages/method calls from the foreground Dart side to manage
- * the background service, e.g. start (enqueue), stop (cancel)
+ * Android plugin for Dart `BackgroundService` and file trash operations
*/
-class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
+class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware, PluginRegistry.ActivityResultListener {
private var methodChannel: MethodChannel? = null
+ private var fileTrashChannel: MethodChannel? = null
private var context: Context? = null
+ private var pendingResult: Result? = null
+ private val permissionRequestCode = 1001
+ private val trashRequestCode = 1002
+ private var activityBinding: ActivityPluginBinding? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
@@ -29,6 +46,10 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
context = ctx
methodChannel = MethodChannel(messenger, "immich/foregroundChannel")
methodChannel?.setMethodCallHandler(this)
+
+ // Add file trash channel
+ fileTrashChannel = MethodChannel(messenger, "file_trash")
+ fileTrashChannel?.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
@@ -38,11 +59,14 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private fun onDetachedFromEngine() {
methodChannel?.setMethodCallHandler(null)
methodChannel = null
+ fileTrashChannel?.setMethodCallHandler(null)
+ fileTrashChannel = null
}
- override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
+ override fun onMethodCall(call: MethodCall, result: Result) {
val ctx = context!!
when (call.method) {
+ // Existing BackgroundService methods
"enable" -> {
val args = call.arguments>()!!
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
@@ -114,10 +138,184 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
}
}
+ // File Trash methods moved from MainActivity
+ "moveToTrash" -> {
+ val mediaUrls = call.argument>("mediaUrls")
+ if (mediaUrls != null) {
+ if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
+ moveToTrash(mediaUrls, result)
+ } else {
+ result.error("PERMISSION_DENIED", "Media permission required", null)
+ }
+ } else {
+ result.error("INVALID_NAME", "The mediaUrls is not specified.", null)
+ }
+ }
+
+ "restoreFromTrash" -> {
+ val fileName = call.argument("fileName")
+ val type = call.argument("type")
+ if (fileName != null && type != null) {
+ if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
+ restoreFromTrash(fileName, type, result)
+ } else {
+ result.error("PERMISSION_DENIED", "Media permission required", null)
+ }
+ } else {
+ result.error("INVALID_NAME", "The file name is not specified.", null)
+ }
+ }
+
+ "requestManageMediaPermission" -> {
+ if (!hasManageMediaPermission()) {
+ requestManageMediaPermission(result)
+ } else {
+ Log.e("Manage storage permission", "Permission already granted")
+ result.success(true)
+ }
+ }
+
else -> result.notImplemented()
}
}
+
+ private fun hasManageMediaPermission(): Boolean {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ MediaStore.canManageMedia(context!!);
+ } else {
+ false
+ }
+ }
+
+ private fun requestManageMediaPermission(result: Result) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ pendingResult = result // Store the result callback
+ val activity = activityBinding?.activity ?: return
+
+ val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA)
+ intent.data = "package:${activity.packageName}".toUri()
+ activity.startActivityForResult(intent, permissionRequestCode)
+ } else {
+ result.success(false)
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.R)
+ private fun moveToTrash(mediaUrls: List, result: Result) {
+ val urisToTrash = mediaUrls.map { it.toUri() }
+ if (urisToTrash.isEmpty()) {
+ result.error("INVALID_ARGS", "No valid URIs provided", null)
+ return
+ }
+
+ toggleTrash(urisToTrash, true, result);
+ }
+
+ @RequiresApi(Build.VERSION_CODES.R)
+ private fun restoreFromTrash(name: String, type: Int, result: Result) {
+ val uri = getTrashedFileUri(name, type)
+ if (uri == null) {
+ Log.e("TrashError", "Asset Uri cannot be found obtained")
+ result.error("TrashError", "Asset Uri cannot be found obtained", null)
+ return
+ }
+ Log.e("FILE_URI", uri.toString())
+ uri.let { toggleTrash(listOf(it), false, result) }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.R)
+ private fun toggleTrash(contentUris: List, isTrashed: Boolean, result: Result) {
+ val activity = activityBinding?.activity
+ val contentResolver = context?.contentResolver
+ if (activity == null || contentResolver == null) {
+ result.error("TrashError", "Activity or ContentResolver not available", null)
+ return
+ }
+
+ try {
+ val pendingIntent = MediaStore.createTrashRequest(contentResolver, contentUris, isTrashed)
+ pendingResult = result // Store for onActivityResult
+ activity.startIntentSenderForResult(
+ pendingIntent.intentSender,
+ trashRequestCode,
+ null, 0, 0, 0
+ )
+ } catch (e: Exception) {
+ Log.e("TrashError", "Error creating or starting trash request", e)
+ result.error("TrashError", "Error creating or starting trash request", null)
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.R)
+ private fun getTrashedFileUri(fileName: String, type: Int): Uri? {
+ val contentResolver = context?.contentResolver ?: return null
+ val queryUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
+ val projection = arrayOf(MediaStore.Files.FileColumns._ID)
+
+ val queryArgs = Bundle().apply {
+ putString(
+ ContentResolver.QUERY_ARG_SQL_SELECTION,
+ "${MediaStore.Files.FileColumns.DISPLAY_NAME} = ?"
+ )
+ putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(fileName))
+ putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
+ }
+
+ contentResolver.query(queryUri, projection, queryArgs, null)?.use { cursor ->
+ if (cursor.moveToFirst()) {
+ val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID))
+ // same order as AssetType from dart
+ val contentUri = when (type) {
+ 1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
+ 2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
+ 3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
+ else -> queryUri
+ }
+ return ContentUris.withAppendedId(contentUri, id)
+ }
+ }
+ return null
+ }
+
+ // ActivityAware implementation
+ override fun onAttachedToActivity(binding: ActivityPluginBinding) {
+ activityBinding = binding
+ binding.addActivityResultListener(this)
+ }
+
+ override fun onDetachedFromActivityForConfigChanges() {
+ activityBinding?.removeActivityResultListener(this)
+ activityBinding = null
+ }
+
+ override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
+ activityBinding = binding
+ binding.addActivityResultListener(this)
+ }
+
+ override fun onDetachedFromActivity() {
+ activityBinding?.removeActivityResultListener(this)
+ activityBinding = null
+ }
+
+ // ActivityResultListener implementation
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
+ if (requestCode == permissionRequestCode) {
+ val granted = hasManageMediaPermission()
+ pendingResult?.success(granted)
+ pendingResult = null
+ return true
+ }
+
+ if (requestCode == trashRequestCode) {
+ val approved = resultCode == Activity.RESULT_OK
+ pendingResult?.success(approved)
+ pendingResult = null
+ return true
+ }
+ return false
+ }
}
private const val TAG = "BackgroundServicePlugin"
-private const val BUFFER_SIZE = 2 * 1024 * 1024;
+private const val BUFFER_SIZE = 2 * 1024 * 1024
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt
index 4ffb490c77..2b6bf81148 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt
@@ -2,14 +2,12 @@ package app.alextran.immich
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
-import android.os.Bundle
-import android.content.Intent
+import androidx.annotation.NonNull
class MainActivity : FlutterActivity() {
-
- override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
+ override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(BackgroundServicePlugin())
+ // No need to set up method channel here as it's now handled in the plugin
}
-
}
diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile
index 13f3b0b850..a0b08bb316 100644
--- a/mobile/android/fastlane/Fastfile
+++ b/mobile/android/fastlane/Fastfile
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
- "android.injected.version.code" => 195,
- "android.injected.version.name" => "1.132.1",
+ "android.injected.version.code" => 197,
+ "android.injected.version.name" => "1.132.3",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj
index e4c25fefdf..744ddc053b 100644
--- a/mobile/ios/Runner.xcodeproj/project.pbxproj
+++ b/mobile/ios/Runner.xcodeproj/project.pbxproj
@@ -261,9 +261,11 @@
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
+ ProvisioningStyle = Automatic;
};
FAC6F88F2D287C890078CB2F = {
CreatedOnToolsVersion = 16.0;
+ ProvisioningStyle = Automatic;
};
};
};
@@ -541,7 +543,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 202;
+ CURRENT_PROJECT_VERSION = 205;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -685,7 +687,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 202;
+ CURRENT_PROJECT_VERSION = 205;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -715,7 +717,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 202;
+ CURRENT_PROJECT_VERSION = 205;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -748,7 +750,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 202;
+ CURRENT_PROJECT_VERSION = 205;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -769,6 +771,7 @@
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
+ PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -791,7 +794,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 202;
+ CURRENT_PROJECT_VERSION = 205;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -811,6 +814,7 @@
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
+ PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
@@ -831,7 +835,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 202;
+ CURRENT_PROJECT_VERSION = 205;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -851,6 +855,7 @@
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
+ PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist
index 02fef7a965..38394f0f1b 100644
--- a/mobile/ios/Runner/Info.plist
+++ b/mobile/ios/Runner/Info.plist
@@ -78,7 +78,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.132.0
+ 1.132.3
CFBundleSignature
????
CFBundleURLTypes
@@ -93,7 +93,7 @@
CFBundleVersion
- 202
+ 205
FLTEnableImpeller
ITSAppUsesNonExemptEncryption
diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile
index f454d24973..3306fef1e2 100644
--- a/mobile/ios/fastlane/Fastfile
+++ b/mobile/ios/fastlane/Fastfile
@@ -18,8 +18,11 @@ default_platform(:ios)
platform :ios do
desc "iOS Release"
lane :release do
+ enable_automatic_code_signing(
+ path: "./Runner.xcodeproj",
+ )
increment_version_number(
- version_number: "1.132.1"
+ version_number: "1.132.3"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,
diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart
index e6d9ecaf48..8a5a908e0d 100644
--- a/mobile/lib/domain/models/store.model.dart
+++ b/mobile/lib/domain/models/store.model.dart
@@ -65,6 +65,7 @@ enum StoreKey {
// Video settings
loadOriginalVideo._(136),
+ manageLocalMediaAndroid._(137),
// Experimental stuff
photoManagerCustomFilter._(1000);
diff --git a/mobile/lib/interfaces/local_files_manager.interface.dart b/mobile/lib/interfaces/local_files_manager.interface.dart
new file mode 100644
index 0000000000..07274b7e29
--- /dev/null
+++ b/mobile/lib/interfaces/local_files_manager.interface.dart
@@ -0,0 +1,5 @@
+abstract interface class ILocalFilesManager {
+ Future moveToTrash(List mediaUrls);
+ Future restoreFromTrash(String fileName, int type);
+ Future requestManageMediaPermission();
+}
diff --git a/mobile/lib/pages/library/library.page.dart b/mobile/lib/pages/library/library.page.dart
index c08a1c715d..1dc336d204 100644
--- a/mobile/lib/pages/library/library.page.dart
+++ b/mobile/lib/pages/library/library.page.dart
@@ -1,7 +1,6 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
-import 'package:geolocator/geolocator.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
@@ -13,7 +12,6 @@ import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
-import 'package:immich_mobile/utils/map_utils.dart';
import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart';
import 'package:immich_mobile/widgets/common/immich_app_bar.dart';
import 'package:immich_mobile/widgets/common/user_avatar.dart';
@@ -357,66 +355,51 @@ class PlacesCollectionCard extends StatelessWidget {
final widthFactor = isTablet ? 0.25 : 0.5;
final size = context.width * widthFactor - 20.0;
- return FutureBuilder<(Position?, LocationPermission?)>(
- future: MapUtils.checkPermAndGetLocation(
- context: context,
- silent: true,
+ return GestureDetector(
+ onTap: () => context.pushRoute(
+ PlacesCollectionRoute(
+ currentLocation: null,
+ ),
),
- builder: (context, snapshot) {
- var position = snapshot.data?.$1;
- return GestureDetector(
- onTap: () => context.pushRoute(
- PlacesCollectionRoute(
- currentLocation: position != null
- ? LatLng(position.latitude, position.longitude)
- : null,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ SizedBox(
+ height: size,
+ width: size,
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ borderRadius: const BorderRadius.all(Radius.circular(20)),
+ color:
+ context.colorScheme.secondaryContainer.withAlpha(100),
+ ),
+ child: IgnorePointer(
+ child: MapThumbnail(
+ zoom: 8,
+ centre: const LatLng(
+ 21.44950,
+ -157.91959,
+ ),
+ showAttribution: false,
+ themeMode: context.isDarkTheme
+ ? ThemeMode.dark
+ : ThemeMode.light,
+ ),
+ ),
),
),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- SizedBox(
- height: size,
- width: size,
- child: DecoratedBox(
- decoration: BoxDecoration(
- borderRadius:
- const BorderRadius.all(Radius.circular(20)),
- color: context.colorScheme.secondaryContainer
- .withAlpha(100),
- ),
- child: IgnorePointer(
- child: snapshot.connectionState ==
- ConnectionState.waiting
- ? const Center(child: CircularProgressIndicator())
- : MapThumbnail(
- zoom: 8,
- centre: LatLng(
- position?.latitude ?? 21.44950,
- position?.longitude ?? -157.91959,
- ),
- showAttribution: false,
- themeMode: context.isDarkTheme
- ? ThemeMode.dark
- : ThemeMode.light,
- ),
- ),
- ),
+ Padding(
+ padding: const EdgeInsets.all(8.0),
+ child: Text(
+ 'places'.tr(),
+ style: context.textTheme.titleSmall?.copyWith(
+ color: context.colorScheme.onSurface,
+ fontWeight: FontWeight.w500,
),
- Padding(
- padding: const EdgeInsets.all(8.0),
- child: Text(
- 'places'.tr(),
- style: context.textTheme.titleSmall?.copyWith(
- color: context.colorScheme.onSurface,
- fontWeight: FontWeight.w500,
- ),
- ),
- ),
- ],
+ ),
),
- );
- },
+ ],
+ ),
);
},
);
diff --git a/mobile/lib/pages/onboarding/permission_onboarding.page.dart b/mobile/lib/pages/onboarding/permission_onboarding.page.dart
index a6768cc207..b0a1b34b06 100644
--- a/mobile/lib/pages/onboarding/permission_onboarding.page.dart
+++ b/mobile/lib/pages/onboarding/permission_onboarding.page.dart
@@ -44,7 +44,7 @@ class PermissionOnboardingPage extends HookConsumerWidget {
}
}),
child: const Text(
- 'grant_permission',
+ 'continue',
).tr(),
),
],
diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart
index f92d2c8421..72dbda8b6f 100644
--- a/mobile/lib/providers/websocket.provider.dart
+++ b/mobile/lib/providers/websocket.provider.dart
@@ -23,6 +23,7 @@ enum PendingAction {
assetDelete,
assetUploaded,
assetHidden,
+ assetTrash,
}
class PendingChange {
@@ -160,7 +161,7 @@ class WebsocketNotifier extends StateNotifier {
socket.on('on_upload_success', _handleOnUploadSuccess);
socket.on('on_config_update', _handleOnConfigUpdate);
socket.on('on_asset_delete', _handleOnAssetDelete);
- socket.on('on_asset_trash', _handleServerUpdates);
+ socket.on('on_asset_trash', _handleOnAssetTrash);
socket.on('on_asset_restore', _handleServerUpdates);
socket.on('on_asset_update', _handleServerUpdates);
socket.on('on_asset_stack_update', _handleServerUpdates);
@@ -207,6 +208,26 @@ class WebsocketNotifier extends StateNotifier {
_debounce.run(handlePendingChanges);
}
+ Future _handlePendingTrashes() async {
+ final trashChanges = state.pendingChanges
+ .where((c) => c.action == PendingAction.assetTrash)
+ .toList();
+ if (trashChanges.isNotEmpty) {
+ List remoteIds = trashChanges
+ .expand((a) => (a.value as List).map((e) => e.toString()))
+ .toList();
+
+ await _ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds);
+ await _ref.read(assetProvider.notifier).getAllAsset();
+
+ state = state.copyWith(
+ pendingChanges: state.pendingChanges
+ .whereNot((c) => trashChanges.contains(c))
+ .toList(),
+ );
+ }
+ }
+
Future _handlePendingDeletes() async {
final deleteChanges = state.pendingChanges
.where((c) => c.action == PendingAction.assetDelete)
@@ -267,6 +288,7 @@ class WebsocketNotifier extends StateNotifier {
await _handlePendingUploaded();
await _handlePendingDeletes();
await _handlingPendingHidden();
+ await _handlePendingTrashes();
}
void _handleOnConfigUpdate(dynamic _) {
@@ -285,6 +307,10 @@ class WebsocketNotifier extends StateNotifier {
void _handleOnAssetDelete(dynamic data) =>
addPendingChange(PendingAction.assetDelete, data);
+ void _handleOnAssetTrash(dynamic data) {
+ addPendingChange(PendingAction.assetTrash, data);
+ }
+
void _handleOnAssetHidden(dynamic data) =>
addPendingChange(PendingAction.assetHidden, data);
diff --git a/mobile/lib/repositories/local_files_manager.repository.dart b/mobile/lib/repositories/local_files_manager.repository.dart
new file mode 100644
index 0000000000..c2e234d14d
--- /dev/null
+++ b/mobile/lib/repositories/local_files_manager.repository.dart
@@ -0,0 +1,25 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
+import 'package:immich_mobile/utils/local_files_manager.dart';
+
+final localFilesManagerRepositoryProvider =
+ Provider((ref) => const LocalFilesManagerRepository());
+
+class LocalFilesManagerRepository implements ILocalFilesManager {
+ const LocalFilesManagerRepository();
+
+ @override
+ Future moveToTrash(List mediaUrls) async {
+ return await LocalFilesManager.moveToTrash(mediaUrls);
+ }
+
+ @override
+ Future restoreFromTrash(String fileName, int type) async {
+ return await LocalFilesManager.restoreFromTrash(fileName, type);
+ }
+
+ @override
+ Future requestManageMediaPermission() async {
+ return await LocalFilesManager.requestManageMediaPermission();
+ }
+}
diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart
index cc57b8d3a3..6413b69fce 100644
--- a/mobile/lib/services/app_settings.service.dart
+++ b/mobile/lib/services/app_settings.service.dart
@@ -61,6 +61,7 @@ enum AppSettingsEnum {
0,
),
advancedTroubleshooting(StoreKey.advancedTroubleshooting, null, false),
+ manageLocalMediaAndroid(StoreKey.manageLocalMediaAndroid, null, false),
logLevel(StoreKey.logLevel, null, 5), // Level.INFO = 5
preferRemoteImage(StoreKey.preferRemoteImage, null, false),
loopVideo(StoreKey.loopVideo, "loopVideo", true),
diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart
index 11a9dcb56a..80950d8c00 100644
--- a/mobile/lib/services/sync.service.dart
+++ b/mobile/lib/services/sync.service.dart
@@ -1,4 +1,5 @@
import 'dart:async';
+import 'dart:io';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -16,8 +17,10 @@ import 'package:immich_mobile/interfaces/album_api.interface.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/etag.interface.dart';
+import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
import 'package:immich_mobile/interfaces/partner.interface.dart';
import 'package:immich_mobile/interfaces/partner_api.interface.dart';
+import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/exif.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/repositories/album.repository.dart';
@@ -25,8 +28,10 @@ import 'package:immich_mobile/repositories/album_api.repository.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/repositories/etag.repository.dart';
+import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/repositories/partner.repository.dart';
import 'package:immich_mobile/repositories/partner_api.repository.dart';
+import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/entity.service.dart';
import 'package:immich_mobile/services/hash.service.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
@@ -48,6 +53,8 @@ final syncServiceProvider = Provider(
ref.watch(userRepositoryProvider),
ref.watch(userServiceProvider),
ref.watch(etagRepositoryProvider),
+ ref.watch(appSettingsServiceProvider),
+ ref.watch(localFilesManagerRepositoryProvider),
ref.watch(partnerApiRepositoryProvider),
ref.watch(userApiRepositoryProvider),
),
@@ -69,6 +76,8 @@ class SyncService {
final IUserApiRepository _userApiRepository;
final AsyncMutex _lock = AsyncMutex();
final Logger _log = Logger('SyncService');
+ final AppSettingsService _appSettingsService;
+ final ILocalFilesManager _localFilesManager;
SyncService(
this._hashService,
@@ -82,6 +91,8 @@ class SyncService {
this._userRepository,
this._userService,
this._eTagRepository,
+ this._appSettingsService,
+ this._localFilesManager,
this._partnerApiRepository,
this._userApiRepository,
);
@@ -238,8 +249,22 @@ class SyncService {
return null;
}
+ Future _moveToTrashMatchedAssets(Iterable idsToDelete) async {
+ final List localAssets = await _assetRepository.getAllLocal();
+ final List matchedAssets = localAssets
+ .where((asset) => idsToDelete.contains(asset.remoteId))
+ .toList();
+
+ final mediaUrls = await Future.wait(
+ matchedAssets
+ .map((asset) => asset.local?.getMediaUrl() ?? Future.value(null)),
+ );
+
+ await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
+ }
+
/// Deletes remote-only assets, updates merged assets to be local-only
- Future handleRemoteAssetRemoval(List idsToDelete) {
+ Future handleRemoteAssetRemoval(List idsToDelete) async {
return _assetRepository.transaction(() async {
await _assetRepository.deleteAllByRemoteId(
idsToDelete,
@@ -249,6 +274,12 @@ class SyncService {
idsToDelete,
state: AssetState.merged,
);
+ if (Platform.isAndroid &&
+ _appSettingsService.getSetting(
+ AppSettingsEnum.manageLocalMediaAndroid,
+ )) {
+ await _moveToTrashMatchedAssets(idsToDelete);
+ }
if (merged.isEmpty) return;
for (final Asset asset in merged) {
asset.remoteId = null;
@@ -790,10 +821,43 @@ class SyncService {
return (existing, toUpsert);
}
+ Future _toggleTrashStatusForAssets(List assetsList) async {
+ final trashMediaUrls = [];
+
+ for (final asset in assetsList) {
+ if (asset.isTrashed) {
+ final mediaUrl = await asset.local?.getMediaUrl();
+ if (mediaUrl == null) {
+ _log.warning(
+ "Failed to get media URL for asset ${asset.name} while moving to trash",
+ );
+ continue;
+ }
+ trashMediaUrls.add(mediaUrl);
+ } else {
+ await _localFilesManager.restoreFromTrash(
+ asset.fileName,
+ asset.type.index,
+ );
+ }
+ }
+
+ if (trashMediaUrls.isNotEmpty) {
+ await _localFilesManager.moveToTrash(trashMediaUrls);
+ }
+ }
+
/// Inserts or updates the assets in the database with their ExifInfo (if any)
Future upsertAssetsWithExif(List assets) async {
if (assets.isEmpty) return;
+ if (Platform.isAndroid &&
+ _appSettingsService.getSetting(
+ AppSettingsEnum.manageLocalMediaAndroid,
+ )) {
+ _toggleTrashStatusForAssets(assets);
+ }
+
try {
await _assetRepository.transaction(() async {
await _assetRepository.updateAll(assets);
diff --git a/mobile/lib/utils/local_files_manager.dart b/mobile/lib/utils/local_files_manager.dart
new file mode 100644
index 0000000000..a4cf41a6e6
--- /dev/null
+++ b/mobile/lib/utils/local_files_manager.dart
@@ -0,0 +1,38 @@
+import 'package:flutter/services.dart';
+import 'package:logging/logging.dart';
+
+abstract final class LocalFilesManager {
+ static final Logger _logger = Logger('LocalFilesManager');
+ static const MethodChannel _channel = MethodChannel('file_trash');
+
+ static Future moveToTrash(List mediaUrls) async {
+ try {
+ return await _channel
+ .invokeMethod('moveToTrash', {'mediaUrls': mediaUrls});
+ } catch (e, s) {
+ _logger.warning('Error moving file to trash', e, s);
+ return false;
+ }
+ }
+
+ static Future restoreFromTrash(String fileName, int type) async {
+ try {
+ return await _channel.invokeMethod(
+ 'restoreFromTrash',
+ {'fileName': fileName, 'type': type},
+ );
+ } catch (e, s) {
+ _logger.warning('Error restore file from trash', e, s);
+ return false;
+ }
+ }
+
+ static Future requestManageMediaPermission() async {
+ try {
+ return await _channel.invokeMethod('requestManageMediaPermission');
+ } catch (e, s) {
+ _logger.warning('Error requesting manage media permission', e, s);
+ return false;
+ }
+ }
+}
diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart
index bebd7a027b..6a09f79ce2 100644
--- a/mobile/lib/utils/migration.dart
+++ b/mobile/lib/utils/migration.dart
@@ -3,7 +3,7 @@
import 'dart:async';
import 'dart:io';
-import 'package:flutter/widgets.dart';
+import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
@@ -17,6 +17,8 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
+// ignore: import_rule_photo_manager
+import 'package:photo_manager/photo_manager.dart';
const int targetVersion = 10;
@@ -69,14 +71,45 @@ Future _migrateDeviceAsset(Isar db) async {
: (await db.iOSDeviceAssets.where().findAll())
.map((i) => _DeviceAsset(assetId: i.id, hash: i.hash))
.toList();
- final localAssets = (await db.assets
- .where()
- .anyOf(ids, (query, id) => query.localIdEqualTo(id.assetId))
- .findAll())
- .map((a) => _DeviceAsset(assetId: a.localId!, dateTime: a.fileModifiedAt))
- .toList();
- debugPrint("Device Asset Ids length - ${ids.length}");
- debugPrint("Local Asset Ids length - ${localAssets.length}");
+
+ final PermissionState ps = await PhotoManager.requestPermissionExtend();
+ if (!ps.hasAccess) {
+ if (kDebugMode) {
+ debugPrint(
+ "[MIGRATION] Photo library permission not granted. Skipping device asset migration.",
+ );
+ }
+
+ return;
+ }
+
+ List<_DeviceAsset> localAssets = [];
+ final List paths =
+ await PhotoManager.getAssetPathList(onlyAll: true);
+
+ if (paths.isEmpty) {
+ localAssets = (await db.assets
+ .where()
+ .anyOf(ids, (query, id) => query.localIdEqualTo(id.assetId))
+ .findAll())
+ .map(
+ (a) => _DeviceAsset(assetId: a.localId!, dateTime: a.fileModifiedAt),
+ )
+ .toList();
+ } else {
+ final AssetPathEntity albumWithAll = paths.first;
+ final int assetCount = await albumWithAll.assetCountAsync;
+
+ final List allDeviceAssets =
+ await albumWithAll.getAssetListRange(start: 0, end: assetCount);
+
+ localAssets = allDeviceAssets
+ .map((a) => _DeviceAsset(assetId: a.id, dateTime: a.modifiedDateTime))
+ .toList();
+ }
+
+ debugPrint("[MIGRATION] Device Asset Ids length - ${ids.length}");
+ debugPrint("[MIGRATION] Local Asset Ids length - ${localAssets.length}");
ids.sort((a, b) => a.assetId.compareTo(b.assetId));
localAssets.sort((a, b) => a.assetId.compareTo(b.assetId));
final List toAdd = [];
@@ -95,15 +128,27 @@ Future _migrateDeviceAsset(Isar db) async {
return false;
},
onlyFirst: (deviceAsset) {
- debugPrint(
- 'DeviceAsset not found in local assets: ${deviceAsset.assetId}',
- );
+ if (kDebugMode) {
+ debugPrint(
+ '[MIGRATION] Local asset not found in DeviceAsset: ${deviceAsset.assetId}',
+ );
+ }
},
onlySecond: (asset) {
- debugPrint('Local asset not found in DeviceAsset: ${asset.assetId}');
+ if (kDebugMode) {
+ debugPrint(
+ '[MIGRATION] Local asset not found in DeviceAsset: ${asset.assetId}',
+ );
+ }
},
);
- debugPrint("Total number of device assets migrated - ${toAdd.length}");
+
+ if (kDebugMode) {
+ debugPrint(
+ "[MIGRATION] Total number of device assets migrated - ${toAdd.length}",
+ );
+ }
+
await db.writeTxn(() async {
await db.deviceAssetEntitys.putAll(toAdd);
});
diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart
index 3433648e9f..5374d1ef33 100644
--- a/mobile/lib/widgets/forms/login/login_form.dart
+++ b/mobile/lib/widgets/forms/login/login_form.dart
@@ -207,9 +207,27 @@ class LoginForm extends HookConsumerWidget {
}
String generateRandomString(int length) {
+ const chars =
+ 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890';
final random = Random.secure();
- return base64Url
- .encode(List.generate(32, (i) => random.nextInt(256)));
+ return String.fromCharCodes(
+ Iterable.generate(
+ length,
+ (_) => chars.codeUnitAt(random.nextInt(chars.length)),
+ ),
+ );
+ }
+
+ List randomBytes(int length) {
+ final random = Random.secure();
+ return List.generate(length, (i) => random.nextInt(256));
+ }
+
+ /// Per specification, the code verifier must be 43-128 characters long
+ /// and consist of characters [A-Z, a-z, 0-9, "-", ".", "_", "~"]
+ /// https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
+ String randomCodeVerifier() {
+ return base64Url.encode(randomBytes(42));
}
Future generatePKCECodeChallenge(String codeVerifier) async {
@@ -223,7 +241,8 @@ class LoginForm extends HookConsumerWidget {
String? oAuthServerUrl;
final state = generateRandomString(32);
- final codeVerifier = generateRandomString(64);
+
+ final codeVerifier = randomCodeVerifier();
final codeChallenge = await generatePKCECodeChallenge(codeVerifier);
try {
diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart
index a2e0e5b95c..d65186a191 100644
--- a/mobile/lib/widgets/settings/advanced_settings.dart
+++ b/mobile/lib/widgets/settings/advanced_settings.dart
@@ -1,11 +1,13 @@
import 'dart:io';
+import 'package:device_info_plus/device_info_plus.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/providers/user.provider.dart';
+import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
@@ -25,6 +27,8 @@ class AdvancedSettings extends HookConsumerWidget {
final advancedTroubleshooting =
useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
+ final manageLocalMediaAndroid =
+ useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final levelId = useAppSettingsState(AppSettingsEnum.logLevel);
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
final allowSelfSignedSSLCert =
@@ -40,6 +44,16 @@ class AdvancedSettings extends HookConsumerWidget {
LogService.I.setlogLevel(Level.LEVELS[levelId.value].toLogLevel()),
);
+ Future checkAndroidVersion() async {
+ if (Platform.isAndroid) {
+ DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
+ AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
+ int sdkVersion = androidInfo.version.sdkInt;
+ return sdkVersion >= 31;
+ }
+ return false;
+ }
+
final advancedSettings = [
SettingsSwitchListTile(
enabled: true,
@@ -47,6 +61,29 @@ class AdvancedSettings extends HookConsumerWidget {
title: "advanced_settings_troubleshooting_title".tr(),
subtitle: "advanced_settings_troubleshooting_subtitle".tr(),
),
+ FutureBuilder(
+ future: checkAndroidVersion(),
+ builder: (context, snapshot) {
+ if (snapshot.hasData && snapshot.data == true) {
+ return SettingsSwitchListTile(
+ enabled: true,
+ valueNotifier: manageLocalMediaAndroid,
+ title: "advanced_settings_sync_remote_deletions_title".tr(),
+ subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(),
+ onChanged: (value) async {
+ if (value) {
+ final result = await ref
+ .read(localFilesManagerRepositoryProvider)
+ .requestManageMediaPermission();
+ manageLocalMediaAndroid.value = result;
+ }
+ },
+ );
+ } else {
+ return const SizedBox.shrink();
+ }
+ },
+ ),
SettingsSliderListTile(
text: "advanced_settings_log_level_title".tr(args: [logLevel]),
valueNotifier: levelId,
diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md
index 073ae932ce..4f9b062ba6 100644
--- a/mobile/openapi/README.md
+++ b/mobile/openapi/README.md
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
-- API version: 1.132.1
+- API version: 1.132.3
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml
index 07f56fb341..08e9661d58 100644
--- a/mobile/pubspec.yaml
+++ b/mobile/pubspec.yaml
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
-version: 1.132.1+195
+version: 1.132.3+197
environment:
sdk: '>=3.3.0 <4.0.0'
diff --git a/mobile/test/modules/shared/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart
index 3879e64237..2029ade018 100644
--- a/mobile/test/modules/shared/sync_service_test.dart
+++ b/mobile/test/modules/shared/sync_service_test.dart
@@ -60,6 +60,9 @@ void main() {
final MockAlbumMediaRepository albumMediaRepository =
MockAlbumMediaRepository();
final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository();
+ final MockAppSettingService appSettingService = MockAppSettingService();
+ final MockLocalFilesManagerRepository localFilesManagerRepository =
+ MockLocalFilesManagerRepository();
final MockPartnerApiRepository partnerApiRepository =
MockPartnerApiRepository();
final MockUserApiRepository userApiRepository = MockUserApiRepository();
@@ -106,6 +109,8 @@ void main() {
userRepository,
userService,
eTagRepository,
+ appSettingService,
+ localFilesManagerRepository,
partnerApiRepository,
userApiRepository,
);
diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart
index 1c698297dc..d2f0da4231 100644
--- a/mobile/test/repository.mocks.dart
+++ b/mobile/test/repository.mocks.dart
@@ -10,6 +10,7 @@ import 'package:immich_mobile/interfaces/auth_api.interface.dart';
import 'package:immich_mobile/interfaces/backup_album.interface.dart';
import 'package:immich_mobile/interfaces/etag.interface.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart';
+import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
import 'package:immich_mobile/interfaces/partner.interface.dart';
import 'package:immich_mobile/interfaces/partner_api.interface.dart';
import 'package:mocktail/mocktail.dart';
@@ -41,6 +42,9 @@ class MockAuthApiRepository extends Mock implements IAuthApiRepository {}
class MockAuthRepository extends Mock implements IAuthRepository {}
+class MockPartnerRepository extends Mock implements IPartnerRepository {}
+
class MockPartnerApiRepository extends Mock implements IPartnerApiRepository {}
-class MockPartnerRepository extends Mock implements IPartnerRepository {}
+class MockLocalFilesManagerRepository extends Mock
+ implements ILocalFilesManager {}
diff --git a/mobile/test/service.mocks.dart b/mobile/test/service.mocks.dart
index d31a7e5d50..87a8c01cf0 100644
--- a/mobile/test/service.mocks.dart
+++ b/mobile/test/service.mocks.dart
@@ -1,5 +1,6 @@
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/api.service.dart';
+import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/services/entity.service.dart';
@@ -25,4 +26,6 @@ class MockNetworkService extends Mock implements NetworkService {}
class MockSearchApi extends Mock implements SearchApi {}
+class MockAppSettingService extends Mock implements AppSettingsService {}
+
class MockBackgroundService extends Mock implements BackgroundService {}
diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json
index 53709f3f0c..f2851d7cf1 100644
--- a/open-api/immich-openapi-specs.json
+++ b/open-api/immich-openapi-specs.json
@@ -7656,7 +7656,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
- "version": "1.132.1",
+ "version": "1.132.3",
"contact": {}
},
"tags": [],
diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json
index fe398ed2bb..c102f594cf 100644
--- a/open-api/typescript-sdk/package-lock.json
+++ b/open-api/typescript-sdk/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@immich/sdk",
- "version": "1.132.1",
+ "version": "1.132.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/sdk",
- "version": "1.132.1",
+ "version": "1.132.3",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"
diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json
index 4afce16f23..70f76512b4 100644
--- a/open-api/typescript-sdk/package.json
+++ b/open-api/typescript-sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
- "version": "1.132.1",
+ "version": "1.132.3",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",
diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts
index 01f476517e..51e17c08ac 100644
--- a/open-api/typescript-sdk/src/fetch-client.ts
+++ b/open-api/typescript-sdk/src/fetch-client.ts
@@ -1,6 +1,6 @@
/**
* Immich
- * 1.132.1
+ * 1.132.3
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
diff --git a/server/package-lock.json b/server/package-lock.json
index cde8bd3a62..b1fdfa1f9d 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "immich",
- "version": "1.132.1",
+ "version": "1.132.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich",
- "version": "1.132.1",
+ "version": "1.132.3",
"hasInstallScript": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
diff --git a/server/package.json b/server/package.json
index f4435ced68..f68ba71564 100644
--- a/server/package.json
+++ b/server/package.json
@@ -1,6 +1,6 @@
{
"name": "immich",
- "version": "1.132.1",
+ "version": "1.132.3",
"description": "",
"author": "",
"private": true,
diff --git a/server/src/app.module.ts b/server/src/app.module.ts
index 05dbc090fc..153b525fe5 100644
--- a/server/src/app.module.ts
+++ b/server/src/app.module.ts
@@ -44,7 +44,7 @@ const imports = [
BullModule.registerQueue(...bull.queues),
ClsModule.forRoot(cls.config),
OpenTelemetryModule.forRoot(otel),
- KyselyModule.forRoot(getKyselyConfig(database.config.kysely)),
+ KyselyModule.forRoot(getKyselyConfig(database.config)),
];
class BaseModule implements OnModuleInit, OnModuleDestroy {
diff --git a/server/src/bin/migrations.ts b/server/src/bin/migrations.ts
index 2ddc6776fb..7b850f6166 100644
--- a/server/src/bin/migrations.ts
+++ b/server/src/bin/migrations.ts
@@ -10,7 +10,7 @@ import { DatabaseRepository } from 'src/repositories/database.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import 'src/schema';
import { schemaDiff, schemaFromCode, schemaFromDatabase } from 'src/sql-tools';
-import { getKyselyConfig } from 'src/utils/database';
+import { asPostgresConnectionConfig, getKyselyConfig } from 'src/utils/database';
const main = async () => {
const command = process.argv[2];
@@ -56,7 +56,7 @@ const main = async () => {
const getDatabaseClient = () => {
const configRepository = new ConfigRepository();
const { database } = configRepository.getEnv();
- return new Kysely(getKyselyConfig(database.config.kysely));
+ return new Kysely(getKyselyConfig(database.config));
};
const runQuery = async (query: string) => {
@@ -105,7 +105,7 @@ const create = (path: string, up: string[], down: string[]) => {
const compare = async () => {
const configRepository = new ConfigRepository();
const { database } = configRepository.getEnv();
- const db = postgres(database.config.kysely);
+ const db = postgres(asPostgresConnectionConfig(database.config));
const source = schemaFromCode();
const target = await schemaFromDatabase(db, {});
diff --git a/server/src/bin/sync-sql.ts b/server/src/bin/sync-sql.ts
index 47e6610a74..b791358a90 100644
--- a/server/src/bin/sync-sql.ts
+++ b/server/src/bin/sync-sql.ts
@@ -78,7 +78,7 @@ class SqlGenerator {
const moduleFixture = await Test.createTestingModule({
imports: [
KyselyModule.forRoot({
- ...getKyselyConfig(database.config.kysely),
+ ...getKyselyConfig(database.config),
log: (event) => {
if (event.level === 'query') {
this.sqlLogger.logQuery(event.query.sql);
diff --git a/server/src/repositories/config.repository.spec.ts b/server/src/repositories/config.repository.spec.ts
index 888d5c33ec..9e9ed71191 100644
--- a/server/src/repositories/config.repository.spec.ts
+++ b/server/src/repositories/config.repository.spec.ts
@@ -80,21 +80,12 @@ describe('getEnv', () => {
const { database } = getEnv();
expect(database).toEqual({
config: {
- kysely: expect.objectContaining({
- host: 'database',
- port: 5432,
- database: 'immich',
- username: 'postgres',
- password: 'postgres',
- }),
- typeorm: expect.objectContaining({
- type: 'postgres',
- host: 'database',
- port: 5432,
- database: 'immich',
- username: 'postgres',
- password: 'postgres',
- }),
+ connectionType: 'parts',
+ host: 'database',
+ port: 5432,
+ database: 'immich',
+ username: 'postgres',
+ password: 'postgres',
},
skipMigrations: false,
vectorExtension: 'vectors',
@@ -110,88 +101,9 @@ describe('getEnv', () => {
it('should use DB_URL', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich';
const { database } = getEnv();
- expect(database.config.kysely).toMatchObject({
- host: 'database1',
- password: 'postgres2',
- user: 'postgres1',
- port: 54_320,
- database: 'immich',
- });
- });
-
- it('should handle sslmode=require', () => {
- process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=require';
-
- const { database } = getEnv();
-
- expect(database.config.kysely).toMatchObject({ ssl: {} });
- });
-
- it('should handle sslmode=prefer', () => {
- process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=prefer';
-
- const { database } = getEnv();
-
- expect(database.config.kysely).toMatchObject({ ssl: {} });
- });
-
- it('should handle sslmode=verify-ca', () => {
- process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-ca';
-
- const { database } = getEnv();
-
- expect(database.config.kysely).toMatchObject({ ssl: {} });
- });
-
- it('should handle sslmode=verify-full', () => {
- process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-full';
-
- const { database } = getEnv();
-
- expect(database.config.kysely).toMatchObject({ ssl: {} });
- });
-
- it('should handle sslmode=no-verify', () => {
- process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=no-verify';
-
- const { database } = getEnv();
-
- expect(database.config.kysely).toMatchObject({ ssl: { rejectUnauthorized: false } });
- });
-
- it('should handle ssl=true', () => {
- process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?ssl=true';
-
- const { database } = getEnv();
-
- expect(database.config.kysely).toMatchObject({ ssl: true });
- });
-
- it('should reject invalid ssl', () => {
- process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?ssl=invalid';
-
- expect(() => getEnv()).toThrowError('Invalid ssl option: invalid');
- });
-
- it('should handle socket: URLs', () => {
- process.env.DB_URL = 'socket:/run/postgresql?db=database1';
-
- const { database } = getEnv();
-
- expect(database.config.kysely).toMatchObject({
- host: '/run/postgresql',
- database: 'database1',
- });
- });
-
- it('should handle sockets in postgres: URLs', () => {
- process.env.DB_URL = 'postgres:///database2?host=/path/to/socket';
-
- const { database } = getEnv();
-
- expect(database.config.kysely).toMatchObject({
- host: '/path/to/socket',
- database: 'database2',
+ expect(database.config).toMatchObject({
+ connectionType: 'url',
+ url: 'postgres://postgres1:postgres2@database1:54320/immich',
});
});
});
diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts
index f689641d4f..9b88a78e6b 100644
--- a/server/src/repositories/config.repository.ts
+++ b/server/src/repositories/config.repository.ts
@@ -7,8 +7,7 @@ import { Request, Response } from 'express';
import { RedisOptions } from 'ioredis';
import { CLS_ID, ClsModuleOptions } from 'nestjs-cls';
import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces';
-import { join, resolve } from 'node:path';
-import { parse } from 'pg-connection-string';
+import { join } from 'node:path';
import { citiesFile, excludePaths, IWorker } from 'src/constants';
import { Telemetry } from 'src/decorators';
import { EnvDto } from 'src/dtos/env.dto';
@@ -22,9 +21,7 @@ import {
QueueName,
} from 'src/enum';
import { DatabaseConnectionParams, VectorExtension } from 'src/types';
-import { isValidSsl, PostgresConnectionConfig } from 'src/utils/database';
import { setDifference } from 'src/utils/set';
-import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js';
export interface EnvData {
host?: string;
@@ -59,7 +56,7 @@ export interface EnvData {
};
database: {
- config: { typeorm: PostgresConnectionOptions & DatabaseConnectionParams; kysely: PostgresConnectionConfig };
+ config: DatabaseConnectionParams;
skipMigrations: boolean;
vectorExtension: VectorExtension;
};
@@ -152,14 +149,10 @@ const getEnv = (): EnvData => {
const isProd = environment === ImmichEnvironment.PRODUCTION;
const buildFolder = dto.IMMICH_BUILD_DATA || '/build';
const folders = {
- // eslint-disable-next-line unicorn/prefer-module
- dist: resolve(`${__dirname}/..`),
geodata: join(buildFolder, 'geodata'),
web: join(buildFolder, 'www'),
};
- const databaseUrl = dto.DB_URL;
-
let redisConfig = {
host: dto.REDIS_HOSTNAME || 'redis',
port: dto.REDIS_PORT || 6379,
@@ -191,30 +184,16 @@ const getEnv = (): EnvData => {
}
}
- const parts = {
- connectionType: 'parts',
- host: dto.DB_HOSTNAME || 'database',
- port: dto.DB_PORT || 5432,
- username: dto.DB_USERNAME || 'postgres',
- password: dto.DB_PASSWORD || 'postgres',
- database: dto.DB_DATABASE_NAME || 'immich',
- } as const;
-
- let parsedOptions: PostgresConnectionConfig = parts;
- if (dto.DB_URL) {
- const parsed = parse(dto.DB_URL);
- if (!isValidSsl(parsed.ssl)) {
- throw new Error(`Invalid ssl option: ${parsed.ssl}`);
- }
-
- parsedOptions = {
- ...parsed,
- ssl: parsed.ssl,
- host: parsed.host ?? undefined,
- port: parsed.port ? Number(parsed.port) : undefined,
- database: parsed.database ?? undefined,
- };
- }
+ const databaseConnection: DatabaseConnectionParams = dto.DB_URL
+ ? { connectionType: 'url', url: dto.DB_URL }
+ : {
+ connectionType: 'parts',
+ host: dto.DB_HOSTNAME || 'database',
+ port: dto.DB_PORT || 5432,
+ username: dto.DB_USERNAME || 'postgres',
+ password: dto.DB_PASSWORD || 'postgres',
+ database: dto.DB_DATABASE_NAME || 'immich',
+ };
return {
host: dto.IMMICH_HOST,
@@ -269,21 +248,7 @@ const getEnv = (): EnvData => {
},
database: {
- config: {
- typeorm: {
- type: 'postgres',
- entities: [],
- migrations: [`${folders.dist}/migrations` + '/*.{js,ts}'],
- subscribers: [],
- migrationsRun: false,
- synchronize: false,
- connectTimeoutMS: 10_000, // 10 seconds
- parseInt8: true,
- ...(databaseUrl ? { connectionType: 'url', url: databaseUrl } : parts),
- },
- kysely: parsedOptions,
- },
-
+ config: databaseConnection,
skipMigrations: dto.DB_SKIP_MIGRATIONS ?? false,
vectorExtension: dto.DB_VECTOR_EXTENSION === 'pgvector' ? DatabaseExtension.VECTOR : DatabaseExtension.VECTORS,
},
diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts
index c70c2cbdd4..a402c9d28d 100644
--- a/server/src/repositories/database.repository.ts
+++ b/server/src/repositories/database.repository.ts
@@ -3,7 +3,7 @@ import AsyncLock from 'async-lock';
import { FileMigrationProvider, Kysely, Migrator, sql, Transaction } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { readdir } from 'node:fs/promises';
-import { join } from 'node:path';
+import { join, resolve } from 'node:path';
import semver from 'semver';
import { EXTENSION_NAMES, POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants';
import { DB } from 'src/db';
@@ -205,8 +205,29 @@ export class DatabaseRepository {
const { rows } = await tableExists.execute(this.db);
const hasTypeOrmMigrations = !!rows[0]?.result;
if (hasTypeOrmMigrations) {
+ // eslint-disable-next-line unicorn/prefer-module
+ const dist = resolve(`${__dirname}/..`);
+
this.logger.debug('Running typeorm migrations');
- const dataSource = new DataSource(database.config.typeorm);
+ const dataSource = new DataSource({
+ type: 'postgres',
+ entities: [],
+ subscribers: [],
+ migrations: [`${dist}/migrations` + '/*.{js,ts}'],
+ migrationsRun: false,
+ synchronize: false,
+ connectTimeoutMS: 10_000, // 10 seconds
+ parseInt8: true,
+ ...(database.config.connectionType === 'url'
+ ? { url: database.config.url }
+ : {
+ host: database.config.host,
+ port: database.config.port,
+ username: database.config.username,
+ password: database.config.password,
+ database: database.config.database,
+ }),
+ });
await dataSource.initialize();
await dataSource.runMigrations(options);
await dataSource.destroy();
diff --git a/server/src/services/backup.service.ts b/server/src/services/backup.service.ts
index dc4f71b992..409d34ab73 100644
--- a/server/src/services/backup.service.ts
+++ b/server/src/services/backup.service.ts
@@ -70,7 +70,7 @@ export class BackupService extends BaseService {
async handleBackupDatabase(): Promise {
this.logger.debug(`Database Backup Started`);
const { database } = this.configRepository.getEnv();
- const config = database.config.typeorm;
+ const config = database.config;
const isUrlConnection = config.connectionType === 'url';
diff --git a/server/src/services/database.service.spec.ts b/server/src/services/database.service.spec.ts
index 4e45ec3ae0..e0ab4a624d 100644
--- a/server/src/services/database.service.spec.ts
+++ b/server/src/services/database.service.spec.ts
@@ -53,22 +53,12 @@ describe(DatabaseService.name, () => {
mockEnvData({
database: {
config: {
- kysely: {
- host: 'database',
- port: 5432,
- user: 'postgres',
- password: 'postgres',
- database: 'immich',
- },
- typeorm: {
- connectionType: 'parts',
- type: 'postgres',
- host: 'database',
- port: 5432,
- username: 'postgres',
- password: 'postgres',
- database: 'immich',
- },
+ connectionType: 'parts',
+ host: 'database',
+ port: 5432,
+ username: 'postgres',
+ password: 'postgres',
+ database: 'immich',
},
skipMigrations: false,
vectorExtension: extension,
@@ -292,22 +282,12 @@ describe(DatabaseService.name, () => {
mockEnvData({
database: {
config: {
- kysely: {
- host: 'database',
- port: 5432,
- user: 'postgres',
- password: 'postgres',
- database: 'immich',
- },
- typeorm: {
- connectionType: 'parts',
- type: 'postgres',
- host: 'database',
- port: 5432,
- username: 'postgres',
- password: 'postgres',
- database: 'immich',
- },
+ connectionType: 'parts',
+ host: 'database',
+ port: 5432,
+ username: 'postgres',
+ password: 'postgres',
+ database: 'immich',
},
skipMigrations: true,
vectorExtension: DatabaseExtension.VECTORS,
@@ -325,22 +305,12 @@ describe(DatabaseService.name, () => {
mockEnvData({
database: {
config: {
- kysely: {
- host: 'database',
- port: 5432,
- user: 'postgres',
- password: 'postgres',
- database: 'immich',
- },
- typeorm: {
- connectionType: 'parts',
- type: 'postgres',
- host: 'database',
- port: 5432,
- username: 'postgres',
- password: 'postgres',
- database: 'immich',
- },
+ connectionType: 'parts',
+ host: 'database',
+ port: 5432,
+ username: 'postgres',
+ password: 'postgres',
+ database: 'immich',
},
skipMigrations: true,
vectorExtension: DatabaseExtension.VECTOR,
diff --git a/server/src/utils/database.spec.ts b/server/src/utils/database.spec.ts
new file mode 100644
index 0000000000..4c6a82ad8f
--- /dev/null
+++ b/server/src/utils/database.spec.ts
@@ -0,0 +1,83 @@
+import { asPostgresConnectionConfig } from 'src/utils/database';
+
+describe('database utils', () => {
+ describe('asPostgresConnectionConfig', () => {
+ it('should handle sslmode=require', () => {
+ expect(
+ asPostgresConnectionConfig({
+ connectionType: 'url',
+ url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=require',
+ }),
+ ).toMatchObject({ ssl: {} });
+ });
+
+ it('should handle sslmode=prefer', () => {
+ expect(
+ asPostgresConnectionConfig({
+ connectionType: 'url',
+ url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=prefer',
+ }),
+ ).toMatchObject({ ssl: {} });
+ });
+
+ it('should handle sslmode=verify-ca', () => {
+ expect(
+ asPostgresConnectionConfig({
+ connectionType: 'url',
+ url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-ca',
+ }),
+ ).toMatchObject({ ssl: {} });
+ });
+
+ it('should handle sslmode=verify-full', () => {
+ expect(
+ asPostgresConnectionConfig({
+ connectionType: 'url',
+ url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-full',
+ }),
+ ).toMatchObject({ ssl: {} });
+ });
+
+ it('should handle sslmode=no-verify', () => {
+ expect(
+ asPostgresConnectionConfig({
+ connectionType: 'url',
+ url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=no-verify',
+ }),
+ ).toMatchObject({ ssl: { rejectUnauthorized: false } });
+ });
+
+ it('should handle ssl=true', () => {
+ expect(
+ asPostgresConnectionConfig({
+ connectionType: 'url',
+ url: 'postgres://postgres1:postgres2@database1:54320/immich?ssl=true',
+ }),
+ ).toMatchObject({ ssl: true });
+ });
+
+ it('should reject invalid ssl', () => {
+ expect(() =>
+ asPostgresConnectionConfig({
+ connectionType: 'url',
+ url: 'postgres://postgres1:postgres2@database1:54320/immich?ssl=invalid',
+ }),
+ ).toThrowError('Invalid ssl option');
+ });
+
+ it('should handle socket: URLs', () => {
+ expect(
+ asPostgresConnectionConfig({ connectionType: 'url', url: 'socket:/run/postgresql?db=database1' }),
+ ).toMatchObject({ host: '/run/postgresql', database: 'database1' });
+ });
+
+ it('should handle sockets in postgres: URLs', () => {
+ expect(
+ asPostgresConnectionConfig({ connectionType: 'url', url: 'postgres:///database2?host=/path/to/socket' }),
+ ).toMatchObject({
+ host: '/path/to/socket',
+ database: 'database2',
+ });
+ });
+ });
+});
diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts
index 1af0aa4b4e..8f0b56597a 100644
--- a/server/src/utils/database.ts
+++ b/server/src/utils/database.ts
@@ -13,33 +13,57 @@ import {
} from 'kysely';
import { PostgresJSDialect } from 'kysely-postgres-js';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
+import { parse } from 'pg-connection-string';
import postgres, { Notice } from 'postgres';
import { columns, Exif, Person } from 'src/database';
import { DB } from 'src/db';
import { AssetFileType } from 'src/enum';
import { TimeBucketSize } from 'src/repositories/asset.repository';
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
+import { DatabaseConnectionParams } from 'src/types';
type Ssl = 'require' | 'allow' | 'prefer' | 'verify-full' | boolean | object;
-export type PostgresConnectionConfig = {
- host?: string;
- password?: string;
- user?: string;
- port?: number;
- database?: string;
- max?: number;
- client_encoding?: string;
- ssl?: Ssl;
- application_name?: string;
- fallback_application_name?: string;
- options?: string;
-};
-
-export const isValidSsl = (ssl?: string | boolean | object): ssl is Ssl =>
+const isValidSsl = (ssl?: string | boolean | object): ssl is Ssl =>
typeof ssl !== 'string' || ssl === 'require' || ssl === 'allow' || ssl === 'prefer' || ssl === 'verify-full';
-export const getKyselyConfig = (options: PostgresConnectionConfig): KyselyConfig => {
+export const asPostgresConnectionConfig = (params: DatabaseConnectionParams) => {
+ if (params.connectionType === 'parts') {
+ return {
+ host: params.host,
+ port: params.port,
+ username: params.username,
+ password: params.password,
+ database: params.database,
+ ssl: undefined,
+ };
+ }
+
+ const { host, port, user, password, database, ...rest } = parse(params.url);
+ let ssl: Ssl | undefined;
+ if (rest.ssl) {
+ if (!isValidSsl(rest.ssl)) {
+ throw new Error(`Invalid ssl option: ${rest.ssl}`);
+ }
+ ssl = rest.ssl;
+ }
+
+ return {
+ host: host ?? undefined,
+ port: port ? Number(port) : undefined,
+ username: user,
+ password,
+ database: database ?? undefined,
+ ssl,
+ };
+};
+
+export const getKyselyConfig = (
+ params: DatabaseConnectionParams,
+ options: Partial>> = {},
+): KyselyConfig => {
+ const config = asPostgresConnectionConfig(params);
+
return {
dialect: new PostgresJSDialect({
postgres: postgres({
@@ -66,6 +90,12 @@ export const getKyselyConfig = (options: PostgresConnectionConfig): KyselyConfig
connection: {
TimeZone: 'UTC',
},
+ host: config.host,
+ port: config.port,
+ username: config.username,
+ password: config.password,
+ database: config.database,
+ ssl: config.ssl,
...options,
}),
}),
diff --git a/server/test/medium/globalSetup.ts b/server/test/medium/globalSetup.ts
index 46eb1a733f..e63c9f5224 100644
--- a/server/test/medium/globalSetup.ts
+++ b/server/test/medium/globalSetup.ts
@@ -1,5 +1,4 @@
import { Kysely } from 'kysely';
-import { parse } from 'pg-connection-string';
import { DB } from 'src/db';
import { ConfigRepository } from 'src/repositories/config.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
@@ -37,19 +36,10 @@ const globalSetup = async () => {
const postgresPort = postgresContainer.getMappedPort(5432);
const postgresUrl = `postgres://postgres:postgres@localhost:${postgresPort}/immich`;
- const parsed = parse(postgresUrl);
process.env.IMMICH_TEST_POSTGRES_URL = postgresUrl;
- const db = new Kysely(
- getKyselyConfig({
- ...parsed,
- ssl: false,
- host: parsed.host ?? undefined,
- port: parsed.port ? Number(parsed.port) : undefined,
- database: parsed.database ?? undefined,
- }),
- );
+ const db = new Kysely(getKyselyConfig({ connectionType: 'url', url: postgresUrl }));
const configRepository = new ConfigRepository();
const logger = new LoggingRepository(undefined, configRepository);
diff --git a/server/test/repositories/config.repository.mock.ts b/server/test/repositories/config.repository.mock.ts
index 7c5450c36e..4943a56a33 100644
--- a/server/test/repositories/config.repository.mock.ts
+++ b/server/test/repositories/config.repository.mock.ts
@@ -21,19 +21,12 @@ const envData: EnvData = {
database: {
config: {
- kysely: { database: 'immich', host: 'database', port: 5432 },
- typeorm: {
- connectionType: 'parts',
- database: 'immich',
- type: 'postgres',
- host: 'database',
- port: 5432,
- username: 'postgres',
- password: 'postgres',
- name: 'immich',
- synchronize: false,
- migrationsRun: true,
- },
+ connectionType: 'parts',
+ database: 'immich',
+ host: 'database',
+ port: 5432,
+ username: 'postgres',
+ password: 'postgres',
},
skipMigrations: false,
diff --git a/server/test/utils.ts b/server/test/utils.ts
index e1d979fbfe..c7c29d310e 100644
--- a/server/test/utils.ts
+++ b/server/test/utils.ts
@@ -1,9 +1,9 @@
import { ClassConstructor } from 'class-transformer';
-import { Kysely, sql } from 'kysely';
+import { Kysely } from 'kysely';
import { ChildProcessWithoutNullStreams } from 'node:child_process';
import { Writable } from 'node:stream';
-import { parse } from 'pg-connection-string';
import { PNG } from 'pngjs';
+import postgres from 'postgres';
import { DB } from 'src/db';
import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository';
@@ -49,7 +49,7 @@ import { VersionHistoryRepository } from 'src/repositories/version-history.repos
import { ViewRepository } from 'src/repositories/view-repository';
import { BaseService } from 'src/services/base.service';
import { RepositoryInterface } from 'src/types';
-import { getKyselyConfig } from 'src/utils/database';
+import { asPostgresConnectionConfig, getKyselyConfig } from 'src/utils/database';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
@@ -297,24 +297,20 @@ function* newPngFactory() {
const pngFactory = newPngFactory();
+const withDatabase = (url: string, name: string) => url.replace('/immich', `/${name}`);
+
export const getKyselyDB = async (suffix?: string): Promise> => {
- const parsed = parse(process.env.IMMICH_TEST_POSTGRES_URL!);
+ const testUrl = process.env.IMMICH_TEST_POSTGRES_URL!;
+ const sql = postgres({
+ ...asPostgresConnectionConfig({ connectionType: 'url', url: withDatabase(testUrl, 'postgres') }),
+ max: 1,
+ });
- const parsedOptions = {
- ...parsed,
- ssl: false,
- host: parsed.host ?? undefined,
- port: parsed.port ? Number(parsed.port) : undefined,
- database: parsed.database ?? undefined,
- };
-
- const kysely = new Kysely(getKyselyConfig({ ...parsedOptions, max: 1, database: 'postgres' }));
const randomSuffix = Math.random().toString(36).slice(2, 7);
const dbName = `immich_${suffix ?? randomSuffix}`;
+ await sql.unsafe(`CREATE DATABASE ${dbName} WITH TEMPLATE immich OWNER postgres;`);
- await sql.raw(`CREATE DATABASE ${dbName} WITH TEMPLATE immich OWNER postgres;`).execute(kysely);
-
- return new Kysely(getKyselyConfig({ ...parsedOptions, database: dbName }));
+ return new Kysely(getKyselyConfig({ connectionType: 'url', url: withDatabase(testUrl, dbName) }));
};
export const newRandomImage = () => {
diff --git a/web/package-lock.json b/web/package-lock.json
index 91d0adb573..37f944d3bb 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "immich-web",
- "version": "1.132.1",
+ "version": "1.132.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-web",
- "version": "1.132.1",
+ "version": "1.132.3",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8",
@@ -82,7 +82,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
- "version": "1.132.1",
+ "version": "1.132.3",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"
diff --git a/web/package.json b/web/package.json
index ec53fd69d5..c32e7b04a8 100644
--- a/web/package.json
+++ b/web/package.json
@@ -1,6 +1,6 @@
{
"name": "immich-web",
- "version": "1.132.1",
+ "version": "1.132.3",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {
diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte
index f7f9b877f3..90f6b3c55b 100644
--- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte
+++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte
@@ -10,11 +10,13 @@
import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte';
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
import { AppRoute } from '$lib/constants';
+ import { authManager } from '$lib/stores/auth-manager.svelte';
+ import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { featureFlags } from '$lib/stores/server-config.store';
+ import { sidebarStore } from '$lib/stores/sidebar.svelte';
import { user } from '$lib/stores/user.store';
import { userInteraction } from '$lib/stores/user.svelte';
- import { handleLogout } from '$lib/utils/auth';
- import { getAboutInfo, logout, type ServerAboutResponseDto } from '@immich/sdk';
+ import { getAboutInfo, type ServerAboutResponseDto } from '@immich/sdk';
import { Button, IconButton } from '@immich/ui';
import { mdiHelpCircleOutline, mdiMagnify, mdiMenu, mdiTrayArrowUp } from '@mdi/js';
import { onMount } from 'svelte';
@@ -23,8 +25,6 @@
import ThemeButton from '../theme-button.svelte';
import UserAvatar from '../user-avatar.svelte';
import AccountInfoPanel from './account-info-panel.svelte';
- import { sidebarStore } from '$lib/stores/sidebar.svelte';
- import { mobileDevice } from '$lib/stores/mobile-device.svelte';
interface Props {
showUploadButton?: boolean;
@@ -38,11 +38,6 @@
let shouldShowHelpPanel = $state(false);
let innerWidth: number = $state(0);
- const onLogout = async () => {
- const { redirectUri } = await logout();
- await handleLogout(redirectUri);
- };
-
let info: ServerAboutResponseDto | undefined = $state();
onMount(async () => {
@@ -183,7 +178,7 @@
{/if}
{#if shouldShowAccountInfoPanel}
-
+ authManager.logout()} />
{/if}
diff --git a/web/src/lib/stores/auth-manager.svelte.ts b/web/src/lib/stores/auth-manager.svelte.ts
new file mode 100644
index 0000000000..72c966df0b
--- /dev/null
+++ b/web/src/lib/stores/auth-manager.svelte.ts
@@ -0,0 +1,33 @@
+import { goto } from '$app/navigation';
+import { AppRoute } from '$lib/constants';
+import { eventManager } from '$lib/stores/event-manager.svelte';
+import { logout } from '@immich/sdk';
+
+class AuthManager {
+ async logout() {
+ let redirectUri;
+
+ try {
+ const response = await logout();
+ if (response.redirectUri) {
+ redirectUri = response.redirectUri;
+ }
+ } catch (error) {
+ console.log('Error logging out:', error);
+ }
+
+ redirectUri = redirectUri ?? AppRoute.AUTH_LOGIN;
+
+ try {
+ if (redirectUri.startsWith('/')) {
+ await goto(redirectUri);
+ } else {
+ globalThis.location.href = redirectUri;
+ }
+ } finally {
+ eventManager.emit('auth.logout');
+ }
+ }
+}
+
+export const authManager = new AuthManager();
diff --git a/web/src/lib/stores/event-manager.svelte.ts b/web/src/lib/stores/event-manager.svelte.ts
new file mode 100644
index 0000000000..09e9b45c3c
--- /dev/null
+++ b/web/src/lib/stores/event-manager.svelte.ts
@@ -0,0 +1,54 @@
+type Listener, K extends keyof EventMap> = (...params: EventMap[K]) => void;
+
+class EventManager> {
+ private listeners: {
+ [K in keyof EventMap]?: {
+ listener: Listener;
+ once?: boolean;
+ }[];
+ } = {};
+
+ on(key: T, listener: (...params: EventMap[T]) => void) {
+ return this.addListener(key, listener, false);
+ }
+
+ once(key: T, listener: (...params: EventMap[T]) => void) {
+ return this.addListener(key, listener, true);
+ }
+
+ off(key: K, listener: Listener) {
+ if (this.listeners[key]) {
+ this.listeners[key] = this.listeners[key].filter((item) => item.listener !== listener);
+ }
+
+ return this;
+ }
+
+ emit(key: T, ...params: EventMap[T]) {
+ if (!this.listeners[key]) {
+ return;
+ }
+
+ for (const { listener } of this.listeners[key]) {
+ listener(...params);
+ }
+
+ // remove one time listeners
+ this.listeners[key] = this.listeners[key].filter((item) => !item.once);
+ }
+
+ private addListener(key: T, listener: (...params: EventMap[T]) => void, once: boolean) {
+ if (!this.listeners[key]) {
+ this.listeners[key] = [];
+ }
+
+ this.listeners[key].push({ listener, once });
+
+ return this;
+ }
+}
+
+export const eventManager = new EventManager<{
+ 'user.login': [];
+ 'auth.logout': [];
+}>();
diff --git a/web/src/lib/stores/folders.svelte.ts b/web/src/lib/stores/folders.svelte.ts
index fb59687a38..c6fc7808b2 100644
--- a/web/src/lib/stores/folders.svelte.ts
+++ b/web/src/lib/stores/folders.svelte.ts
@@ -1,3 +1,4 @@
+import { eventManager } from '$lib/stores/event-manager.svelte';
import {
getAssetsByOriginalPath,
getUniqueOriginalPaths,
@@ -16,6 +17,10 @@ class FoldersStore {
uniquePaths = $state([]);
assets = $state({});
+ constructor() {
+ eventManager.on('auth.logout', () => this.clearCache());
+ }
+
async fetchUniquePaths() {
if (this.initialized) {
return;
diff --git a/web/src/lib/stores/memory.store.svelte.ts b/web/src/lib/stores/memory.store.svelte.ts
index 7173b43d06..ef3f87a3aa 100644
--- a/web/src/lib/stores/memory.store.svelte.ts
+++ b/web/src/lib/stores/memory.store.svelte.ts
@@ -1,3 +1,4 @@
+import { eventManager } from '$lib/stores/event-manager.svelte';
import { asLocalTimeISO } from '$lib/utils/date-time';
import {
type AssetResponseDto,
@@ -24,6 +25,10 @@ export type MemoryAsset = MemoryIndex & {
};
class MemoryStoreSvelte {
+ constructor() {
+ eventManager.on('auth.logout', () => this.clearCache());
+ }
+
memories = $state([]);
private initialized = false;
private memoryAssets = $derived.by(() => {
diff --git a/web/src/lib/stores/search.svelte.ts b/web/src/lib/stores/search.svelte.ts
index 7d012922ca..f334f53460 100644
--- a/web/src/lib/stores/search.svelte.ts
+++ b/web/src/lib/stores/search.svelte.ts
@@ -1,7 +1,13 @@
+import { eventManager } from '$lib/stores/event-manager.svelte';
+
class SearchStore {
savedSearchTerms = $state([]);
isSearchEnabled = $state(false);
+ constructor() {
+ eventManager.on('auth.logout', () => this.clearCache());
+ }
+
clearCache() {
this.savedSearchTerms = [];
this.isSearchEnabled = false;
diff --git a/web/src/lib/stores/user.store.ts b/web/src/lib/stores/user.store.ts
index 5bffc08b80..fe2288c252 100644
--- a/web/src/lib/stores/user.store.ts
+++ b/web/src/lib/stores/user.store.ts
@@ -1,3 +1,4 @@
+import { eventManager } from '$lib/stores/event-manager.svelte';
import { purchaseStore } from '$lib/stores/purchase.store';
import { type UserAdminResponseDto, type UserPreferencesResponseDto } from '@immich/sdk';
import { writable } from 'svelte/store';
@@ -14,3 +15,5 @@ export const resetSavedUser = () => {
preferences.set(undefined as unknown as UserPreferencesResponseDto);
purchaseStore.setPurchaseStatus(false);
};
+
+eventManager.on('auth.logout', () => resetSavedUser());
diff --git a/web/src/lib/stores/user.svelte.ts b/web/src/lib/stores/user.svelte.ts
index 71b2cdd847..093d90e4b5 100644
--- a/web/src/lib/stores/user.svelte.ts
+++ b/web/src/lib/stores/user.svelte.ts
@@ -1,3 +1,4 @@
+import { eventManager } from '$lib/stores/event-manager.svelte';
import type {
AlbumResponseDto,
ServerAboutResponseDto,
@@ -19,8 +20,10 @@ const defaultUserInteraction: UserInteractions = {
serverInfo: undefined,
};
-export const resetUserInteraction = () => {
+export const userInteraction = $state(defaultUserInteraction);
+
+const reset = () => {
Object.assign(userInteraction, defaultUserInteraction);
};
-export const userInteraction = $state(defaultUserInteraction);
+eventManager.on('auth.logout', () => reset());
diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts
index d398ca52a9..90228a5cbd 100644
--- a/web/src/lib/stores/websocket.ts
+++ b/web/src/lib/stores/websocket.ts
@@ -1,5 +1,4 @@
-import { AppRoute } from '$lib/constants';
-import { handleLogout } from '$lib/utils/auth';
+import { authManager } from '$lib/stores/auth-manager.svelte';
import { createEventEmitter } from '$lib/utils/eventemitter';
import type { AssetResponseDto, ServerVersionResponseDto } from '@immich/sdk';
import { io, type Socket } from 'socket.io-client';
@@ -50,7 +49,7 @@ websocket
.on('disconnect', () => websocketStore.connected.set(false))
.on('on_server_version', (serverVersion) => websocketStore.serverVersion.set(serverVersion))
.on('on_new_release', (releaseVersion) => websocketStore.release.set(releaseVersion))
- .on('on_session_delete', () => handleLogout(AppRoute.AUTH_LOGIN))
+ .on('on_session_delete', () => authManager.logout())
.on('connect_error', (e) => console.log('Websocket Connect Error', e));
export const openWebsocketConnection = () => {
diff --git a/web/src/lib/utils/auth.ts b/web/src/lib/utils/auth.ts
index 22b92dd988..9b78c345e2 100644
--- a/web/src/lib/utils/auth.ts
+++ b/web/src/lib/utils/auth.ts
@@ -1,11 +1,7 @@
import { browser } from '$app/environment';
-import { goto } from '$app/navigation';
-import { foldersStore } from '$lib/stores/folders.svelte';
-import { memoryStore } from '$lib/stores/memory.store.svelte';
import { purchaseStore } from '$lib/stores/purchase.store';
-import { searchStore } from '$lib/stores/search.svelte';
-import { preferences as preferences$, resetSavedUser, user as user$ } from '$lib/stores/user.store';
-import { resetUserInteraction, userInteraction } from '$lib/stores/user.svelte';
+import { preferences as preferences$, user as user$ } from '$lib/stores/user.store';
+import { userInteraction } from '$lib/stores/user.svelte';
import { getAboutInfo, getMyPreferences, getMyUser, getStorage } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import { DateTime } from 'luxon';
@@ -91,19 +87,3 @@ export const getAccountAge = (): number => {
return Number(accountAge);
};
-
-export const handleLogout = async (redirectUri: string) => {
- try {
- if (redirectUri.startsWith('/')) {
- await goto(redirectUri);
- } else {
- globalThis.location.href = redirectUri;
- }
- } finally {
- resetSavedUser();
- resetUserInteraction();
- foldersStore.clearCache();
- memoryStore.clearCache();
- searchStore.clearCache();
- }
-};
diff --git a/web/src/lib/utils/focus-util.ts b/web/src/lib/utils/focus-util.ts
index 5bce9954e7..c95ed3f31d 100644
--- a/web/src/lib/utils/focus-util.ts
+++ b/web/src/lib/utils/focus-util.ts
@@ -26,35 +26,14 @@ export const focusNext = (selector: (element: HTMLElement | SVGElement) => boole
focusElements[0].focus();
return;
}
- if (forwardDirection) {
- let i = index + 1;
- while (i !== index) {
- const next = focusElements[i];
- if (!isTabbable(next) || !selector(next)) {
- if (i === focusElements.length - 1) {
- i = 0;
- } else {
- i++;
- }
- continue;
- }
+ const totalElements = focusElements.length;
+ let i = index;
+ do {
+ i = (i + (forwardDirection ? 1 : -1) + totalElements) % totalElements;
+ const next = focusElements[i];
+ if (isTabbable(next) && selector(next)) {
next.focus();
break;
}
- } else {
- let i = index - 1;
- while (i !== index && i >= 0) {
- const next = focusElements[i];
- if (!isTabbable(next) || !selector(next)) {
- if (i === 0) {
- i = focusElements.length - 1;
- } else {
- i--;
- }
- continue;
- }
- next.focus();
- break;
- }
- }
+ } while (i !== index);
};
diff --git a/web/src/routes/auth/change-password/+page.svelte b/web/src/routes/auth/change-password/+page.svelte
index 33d354552e..16a6ffc677 100644
--- a/web/src/routes/auth/change-password/+page.svelte
+++ b/web/src/routes/auth/change-password/+page.svelte
@@ -1,9 +1,8 @@