diff --git a/.vscode/settings.json b/.vscode/settings.json
index 49692809bc..396755a634 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,45 +1,63 @@
{
- "editor.formatOnSave": true,
- "[javascript]": {
- "editor.defaultFormatter": "esbenp.prettier-vscode",
- "editor.tabSize": 2,
- "editor.formatOnSave": true
- },
- "[typescript]": {
- "editor.defaultFormatter": "esbenp.prettier-vscode",
- "editor.tabSize": 2,
- "editor.formatOnSave": true
- },
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
- "editor.tabSize": 2,
- "editor.formatOnSave": true
- },
- "[svelte]": {
- "editor.defaultFormatter": "svelte.svelte-vscode",
+ "editor.formatOnSave": true,
"editor.tabSize": 2
},
- "svelte.enable-ts-plugin": true,
- "eslint.validate": [
- "javascript",
- "svelte"
- ],
- "typescript.preferences.importModuleSpecifier": "non-relative",
"[dart]": {
+ "editor.defaultFormatter": "Dart-Code.dart-code",
"editor.formatOnSave": true,
"editor.selectionHighlight": false,
"editor.suggest.snippetsPreventQuickSuggestions": false,
"editor.suggestSelection": "first",
"editor.tabCompletion": "onlySnippets",
- "editor.wordBasedSuggestions": "off",
- "editor.defaultFormatter": "Dart-Code.dart-code"
+ "editor.wordBasedSuggestions": "off"
},
- "cSpell.words": [
- "immich"
- ],
+ "[javascript]": {
+ "editor.codeActionsOnSave": {
+ "source.organizeImports": "explicit",
+ "source.removeUnusedImports": "explicit"
+ },
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "editor.formatOnSave": true,
+ "editor.tabSize": 2
+ },
+ "[json]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "editor.formatOnSave": true,
+ "editor.tabSize": 2
+ },
+ "[jsonc]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "editor.formatOnSave": true,
+ "editor.tabSize": 2
+ },
+ "[svelte]": {
+ "editor.codeActionsOnSave": {
+ "source.organizeImports": "explicit",
+ "source.removeUnusedImports": "explicit"
+ },
+ "editor.defaultFormatter": "svelte.svelte-vscode",
+ "editor.formatOnSave": true,
+ "editor.tabSize": 2
+ },
+ "[typescript]": {
+ "editor.codeActionsOnSave": {
+ "source.organizeImports": "explicit",
+ "source.removeUnusedImports": "explicit"
+ },
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "editor.formatOnSave": true,
+ "editor.tabSize": 2
+ },
+ "cSpell.words": ["immich"],
+ "editor.formatOnSave": true,
+ "eslint.validate": ["javascript", "svelte"],
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
- "*.ts": "${capture}.spec.ts,${capture}.mock.ts",
- "*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart"
- }
-}
\ No newline at end of file
+ "*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
+ "*.ts": "${capture}.spec.ts,${capture}.mock.ts"
+ },
+ "svelte.enable-ts-plugin": true,
+ "typescript.preferences.importModuleSpecifier": "non-relative"
+}
diff --git a/Makefile b/Makefile
index e15faa8051..1e7760ae68 100644
--- a/Makefile
+++ b/Makefile
@@ -17,6 +17,9 @@ e2e:
prod:
docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
+prod-down:
+ docker compose -f ./docker/docker-compose.prod.yml down --remove-orphans
+
prod-scale:
docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
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/api/specs/user-admin.e2e-spec.ts b/e2e/src/api/specs/user-admin.e2e-spec.ts
index 9299e62b79..1fbee84c3f 100644
--- a/e2e/src/api/specs/user-admin.e2e-spec.ts
+++ b/e2e/src/api/specs/user-admin.e2e-spec.ts
@@ -215,6 +215,19 @@ describe('/admin/users', () => {
const user = await getMyUser({ headers: asBearerAuth(token.accessToken) });
expect(user).toMatchObject({ email: nonAdmin.userEmail });
});
+
+ it('should update the avatar color', async () => {
+ const { status, body } = await request(app)
+ .put(`/admin/users/${admin.userId}`)
+ .send({ avatarColor: 'orange' })
+ .set('Authorization', `Bearer ${admin.accessToken}`);
+
+ expect(status).toBe(200);
+ expect(body).toMatchObject({ avatarColor: 'orange' });
+
+ const after = await getUserAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) });
+ expect(after).toMatchObject({ avatarColor: 'orange' });
+ });
});
describe('PUT /admin/users/:id/preferences', () => {
@@ -240,19 +253,6 @@ describe('/admin/users', () => {
expect(after).toMatchObject({ memories: { enabled: false } });
});
- it('should update the avatar color', async () => {
- const { status, body } = await request(app)
- .put(`/admin/users/${admin.userId}/preferences`)
- .send({ avatar: { color: 'orange' } })
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toMatchObject({ avatar: { color: 'orange' } });
-
- const after = await getUserPreferencesAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) });
- expect(after).toMatchObject({ avatar: { color: 'orange' } });
- });
-
it('should update download archive size', async () => {
const { status, body } = await request(app)
.put(`/admin/users/${admin.userId}/preferences`)
diff --git a/e2e/src/api/specs/user.e2e-spec.ts b/e2e/src/api/specs/user.e2e-spec.ts
index 54d11e5049..b9eb140c56 100644
--- a/e2e/src/api/specs/user.e2e-spec.ts
+++ b/e2e/src/api/specs/user.e2e-spec.ts
@@ -139,6 +139,19 @@ describe('/users', () => {
profileChangedAt: expect.anything(),
});
});
+
+ it('should update avatar color', async () => {
+ const { status, body } = await request(app)
+ .put(`/users/me`)
+ .send({ avatarColor: 'blue' })
+ .set('Authorization', `Bearer ${admin.accessToken}`);
+
+ expect(status).toBe(200);
+ expect(body).toMatchObject({ avatarColor: 'blue' });
+
+ const after = await getMyUser({ headers: asBearerAuth(admin.accessToken) });
+ expect(after).toMatchObject({ avatarColor: 'blue' });
+ });
});
describe('PUT /users/me/preferences', () => {
@@ -158,19 +171,6 @@ describe('/users', () => {
expect(after).toMatchObject({ memories: { enabled: false } });
});
- it('should update avatar color', async () => {
- const { status, body } = await request(app)
- .put(`/users/me/preferences`)
- .send({ avatar: { color: 'blue' } })
- .set('Authorization', `Bearer ${admin.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toMatchObject({ avatar: { color: 'blue' } });
-
- const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
- expect(after).toMatchObject({ avatar: { color: 'blue' } });
- });
-
it('should require an integer for download archive size', async () => {
const { status, body } = await request(app)
.put(`/users/me/preferences`)
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/e2e/src/web/specs/photo-viewer.e2e-spec.ts b/e2e/src/web/specs/photo-viewer.e2e-spec.ts
index 4871e7522c..c8a9b42b2a 100644
--- a/e2e/src/web/specs/photo-viewer.e2e-spec.ts
+++ b/e2e/src/web/specs/photo-viewer.e2e-spec.ts
@@ -21,23 +21,9 @@ test.describe('Photo Viewer', () => {
test.beforeEach(async ({ context, page }) => {
// before each test, login as user
await utils.setAuthCookies(context, admin.accessToken);
- await page.goto('/photos');
await page.waitForLoadState('networkidle');
});
- test('initially shows a loading spinner', async ({ page }) => {
- await page.route(`/api/assets/${asset.id}/thumbnail**`, async (route) => {
- // slow down the request for thumbnail, so spinner has chance to show up
- await new Promise((f) => setTimeout(f, 2000));
- await route.continue();
- });
- await page.goto(`/photos/${asset.id}`);
- await page.waitForLoadState('load');
- // this is the spinner
- await page.waitForSelector('svg[role=status]');
- await expect(page.getByTestId('loading-spinner')).toBeVisible();
- });
-
test('loads original photo when zoomed', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
diff --git a/i18n/en.json b/i18n/en.json
index eafb3415d5..239936471d 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -853,10 +853,12 @@
"failed_to_keep_this_delete_others": "Failed to keep this asset and delete the other assets",
"failed_to_load_asset": "Failed to load asset",
"failed_to_load_assets": "Failed to load assets",
+ "failed_to_load_notifications": "Failed to load notifications",
"failed_to_load_people": "Failed to load people",
"failed_to_remove_product_key": "Failed to remove product key",
"failed_to_stack_assets": "Failed to stack assets",
"failed_to_unstack_assets": "Failed to un-stack assets",
+ "failed_to_update_notification_status": "Failed to update notification status",
"import_path_already_exists": "This import path already exists.",
"incorrect_email_or_password": "Incorrect email or password",
"paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation",
@@ -1199,6 +1201,9 @@
"map_settings_only_show_favorites": "Show Favorite Only",
"map_settings_theme_settings": "Map Theme",
"map_zoom_to_see_photos": "Zoom out to see photos",
+ "mark_as_read": "Mark as read",
+ "mark_all_as_read": "Mark all as read",
+ "marked_all_as_read": "Marked all as read",
"matches": "Matches",
"media_type": "Media type",
"memories": "Memories",
@@ -1260,6 +1265,7 @@
"no_places": "No places",
"no_results": "No results",
"no_results_description": "Try a synonym or more general keyword",
+ "no_notifications": "No notifications",
"no_shared_albums_message": "Create an album to share photos and videos with people in your network",
"not_in_any_album": "Not in any album",
"not_selected": "Not selected",
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/asset_grid/immich_asset_grid.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid.dart
index 2ec01e871f..da4c47e466 100644
--- a/mobile/lib/widgets/asset_grid/immich_asset_grid.dart
+++ b/mobile/lib/widgets/asset_grid/immich_asset_grid.dart
@@ -97,6 +97,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
);
if (7 - scaleFactor.value.toInt() != perRow.value) {
perRow.value = 7 - scaleFactor.value.toInt();
+ settings.setSetting(AppSettingsEnum.tilesPerRow, perRow.value);
}
};
}),
diff --git a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart
index a7141c33b2..060898e270 100644
--- a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart
+++ b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart
@@ -755,7 +755,7 @@ class _MonthTitle extends StatelessWidget {
key: Key("month-$title"),
padding: const EdgeInsets.only(left: 12.0, top: 24.0),
child: Text(
- title,
+ toBeginningOfSentenceCase(title, context.locale.languageCode),
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.w500,
@@ -786,7 +786,7 @@ class _Title extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GroupDividerTitle(
- text: title,
+ text: toBeginningOfSentenceCase(title, context.locale.languageCode),
multiselectEnabled: selectionActive,
onSelect: () => selectAssets(assets),
onDeselect: () => deselectAssets(assets),
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..b8ea4b924c 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
@@ -145,8 +145,15 @@ Class | Method | HTTP request | Description
*MemoriesApi* | [**removeMemoryAssets**](doc//MemoriesApi.md#removememoryassets) | **DELETE** /memories/{id}/assets |
*MemoriesApi* | [**searchMemories**](doc//MemoriesApi.md#searchmemories) | **GET** /memories |
*MemoriesApi* | [**updateMemory**](doc//MemoriesApi.md#updatememory) | **PUT** /memories/{id} |
-*NotificationsAdminApi* | [**getNotificationTemplateAdmin**](doc//NotificationsAdminApi.md#getnotificationtemplateadmin) | **POST** /notifications/admin/templates/{name} |
-*NotificationsAdminApi* | [**sendTestEmailAdmin**](doc//NotificationsAdminApi.md#sendtestemailadmin) | **POST** /notifications/admin/test-email |
+*NotificationsApi* | [**deleteNotification**](doc//NotificationsApi.md#deletenotification) | **DELETE** /notifications/{id} |
+*NotificationsApi* | [**deleteNotifications**](doc//NotificationsApi.md#deletenotifications) | **DELETE** /notifications |
+*NotificationsApi* | [**getNotification**](doc//NotificationsApi.md#getnotification) | **GET** /notifications/{id} |
+*NotificationsApi* | [**getNotifications**](doc//NotificationsApi.md#getnotifications) | **GET** /notifications |
+*NotificationsApi* | [**updateNotification**](doc//NotificationsApi.md#updatenotification) | **PUT** /notifications/{id} |
+*NotificationsApi* | [**updateNotifications**](doc//NotificationsApi.md#updatenotifications) | **PUT** /notifications |
+*NotificationsAdminApi* | [**createNotification**](doc//NotificationsAdminApi.md#createnotification) | **POST** /admin/notifications |
+*NotificationsAdminApi* | [**getNotificationTemplateAdmin**](doc//NotificationsAdminApi.md#getnotificationtemplateadmin) | **POST** /admin/notifications/templates/{name} |
+*NotificationsAdminApi* | [**sendTestEmailAdmin**](doc//NotificationsAdminApi.md#sendtestemailadmin) | **POST** /admin/notifications/test-email |
*OAuthApi* | [**finishOAuth**](doc//OAuthApi.md#finishoauth) | **POST** /oauth/callback |
*OAuthApi* | [**linkOAuthAccount**](doc//OAuthApi.md#linkoauthaccount) | **POST** /oauth/link |
*OAuthApi* | [**redirectOAuthToMobile**](doc//OAuthApi.md#redirectoauthtomobile) | **GET** /oauth/mobile-redirect |
@@ -300,7 +307,6 @@ Class | Method | HTTP request | Description
- [AssetStatsResponseDto](doc//AssetStatsResponseDto.md)
- [AssetTypeEnum](doc//AssetTypeEnum.md)
- [AudioCodec](doc//AudioCodec.md)
- - [AvatarResponse](doc//AvatarResponse.md)
- [AvatarUpdate](doc//AvatarUpdate.md)
- [BulkIdResponseDto](doc//BulkIdResponseDto.md)
- [BulkIdsDto](doc//BulkIdsDto.md)
@@ -361,6 +367,13 @@ Class | Method | HTTP request | Description
- [MemoryUpdateDto](doc//MemoryUpdateDto.md)
- [MergePersonDto](doc//MergePersonDto.md)
- [MetadataSearchDto](doc//MetadataSearchDto.md)
+ - [NotificationCreateDto](doc//NotificationCreateDto.md)
+ - [NotificationDeleteAllDto](doc//NotificationDeleteAllDto.md)
+ - [NotificationDto](doc//NotificationDto.md)
+ - [NotificationLevel](doc//NotificationLevel.md)
+ - [NotificationType](doc//NotificationType.md)
+ - [NotificationUpdateAllDto](doc//NotificationUpdateAllDto.md)
+ - [NotificationUpdateDto](doc//NotificationUpdateDto.md)
- [OAuthAuthorizeResponseDto](doc//OAuthAuthorizeResponseDto.md)
- [OAuthCallbackDto](doc//OAuthCallbackDto.md)
- [OAuthConfigDto](doc//OAuthConfigDto.md)
diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart
index ff5a95bbbc..e845099bd2 100644
--- a/mobile/openapi/lib/api.dart
+++ b/mobile/openapi/lib/api.dart
@@ -44,6 +44,7 @@ part 'api/jobs_api.dart';
part 'api/libraries_api.dart';
part 'api/map_api.dart';
part 'api/memories_api.dart';
+part 'api/notifications_api.dart';
part 'api/notifications_admin_api.dart';
part 'api/o_auth_api.dart';
part 'api/partners_api.dart';
@@ -107,7 +108,6 @@ part 'model/asset_stack_response_dto.dart';
part 'model/asset_stats_response_dto.dart';
part 'model/asset_type_enum.dart';
part 'model/audio_codec.dart';
-part 'model/avatar_response.dart';
part 'model/avatar_update.dart';
part 'model/bulk_id_response_dto.dart';
part 'model/bulk_ids_dto.dart';
@@ -168,6 +168,13 @@ part 'model/memory_type.dart';
part 'model/memory_update_dto.dart';
part 'model/merge_person_dto.dart';
part 'model/metadata_search_dto.dart';
+part 'model/notification_create_dto.dart';
+part 'model/notification_delete_all_dto.dart';
+part 'model/notification_dto.dart';
+part 'model/notification_level.dart';
+part 'model/notification_type.dart';
+part 'model/notification_update_all_dto.dart';
+part 'model/notification_update_dto.dart';
part 'model/o_auth_authorize_response_dto.dart';
part 'model/o_auth_callback_dto.dart';
part 'model/o_auth_config_dto.dart';
diff --git a/mobile/openapi/lib/api/notifications_admin_api.dart b/mobile/openapi/lib/api/notifications_admin_api.dart
index c58bf8978d..409683a950 100644
--- a/mobile/openapi/lib/api/notifications_admin_api.dart
+++ b/mobile/openapi/lib/api/notifications_admin_api.dart
@@ -16,7 +16,54 @@ class NotificationsAdminApi {
final ApiClient apiClient;
- /// Performs an HTTP 'POST /notifications/admin/templates/{name}' operation and returns the [Response].
+ /// Performs an HTTP 'POST /admin/notifications' operation and returns the [Response].
+ /// Parameters:
+ ///
+ /// * [NotificationCreateDto] notificationCreateDto (required):
+ Future createNotificationWithHttpInfo(NotificationCreateDto notificationCreateDto,) async {
+ // ignore: prefer_const_declarations
+ final apiPath = r'/admin/notifications';
+
+ // ignore: prefer_final_locals
+ Object? postBody = notificationCreateDto;
+
+ final queryParams = [];
+ final headerParams = {};
+ final formParams = {};
+
+ const contentTypes = ['application/json'];
+
+
+ return apiClient.invokeAPI(
+ apiPath,
+ 'POST',
+ queryParams,
+ postBody,
+ headerParams,
+ formParams,
+ contentTypes.isEmpty ? null : contentTypes.first,
+ );
+ }
+
+ /// Parameters:
+ ///
+ /// * [NotificationCreateDto] notificationCreateDto (required):
+ Future createNotification(NotificationCreateDto notificationCreateDto,) async {
+ final response = await createNotificationWithHttpInfo(notificationCreateDto,);
+ if (response.statusCode >= HttpStatus.badRequest) {
+ throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+ }
+ // When a remote server returns no body with a status of 204, we shall not decode it.
+ // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+ // FormatException when trying to decode an empty string.
+ if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+ return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'NotificationDto',) as NotificationDto;
+
+ }
+ return null;
+ }
+
+ /// Performs an HTTP 'POST /admin/notifications/templates/{name}' operation and returns the [Response].
/// Parameters:
///
/// * [String] name (required):
@@ -24,7 +71,7 @@ class NotificationsAdminApi {
/// * [TemplateDto] templateDto (required):
Future getNotificationTemplateAdminWithHttpInfo(String name, TemplateDto templateDto,) async {
// ignore: prefer_const_declarations
- final apiPath = r'/notifications/admin/templates/{name}'
+ final apiPath = r'/admin/notifications/templates/{name}'
.replaceAll('{name}', name);
// ignore: prefer_final_locals
@@ -68,13 +115,13 @@ class NotificationsAdminApi {
return null;
}
- /// Performs an HTTP 'POST /notifications/admin/test-email' operation and returns the [Response].
+ /// Performs an HTTP 'POST /admin/notifications/test-email' operation and returns the [Response].
/// Parameters:
///
/// * [SystemConfigSmtpDto] systemConfigSmtpDto (required):
Future sendTestEmailAdminWithHttpInfo(SystemConfigSmtpDto systemConfigSmtpDto,) async {
// ignore: prefer_const_declarations
- final apiPath = r'/notifications/admin/test-email';
+ final apiPath = r'/admin/notifications/test-email';
// ignore: prefer_final_locals
Object? postBody = systemConfigSmtpDto;
diff --git a/mobile/openapi/lib/api/notifications_api.dart b/mobile/openapi/lib/api/notifications_api.dart
new file mode 100644
index 0000000000..501cc70a29
--- /dev/null
+++ b/mobile/openapi/lib/api/notifications_api.dart
@@ -0,0 +1,311 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.18
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+
+class NotificationsApi {
+ NotificationsApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
+
+ final ApiClient apiClient;
+
+ /// Performs an HTTP 'DELETE /notifications/{id}' operation and returns the [Response].
+ /// Parameters:
+ ///
+ /// * [String] id (required):
+ Future deleteNotificationWithHttpInfo(String id,) async {
+ // ignore: prefer_const_declarations
+ final apiPath = r'/notifications/{id}'
+ .replaceAll('{id}', id);
+
+ // ignore: prefer_final_locals
+ Object? postBody;
+
+ final queryParams = [];
+ final headerParams = {};
+ final formParams = {};
+
+ const contentTypes = [];
+
+
+ return apiClient.invokeAPI(
+ apiPath,
+ 'DELETE',
+ queryParams,
+ postBody,
+ headerParams,
+ formParams,
+ contentTypes.isEmpty ? null : contentTypes.first,
+ );
+ }
+
+ /// Parameters:
+ ///
+ /// * [String] id (required):
+ Future deleteNotification(String id,) async {
+ final response = await deleteNotificationWithHttpInfo(id,);
+ if (response.statusCode >= HttpStatus.badRequest) {
+ throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+ }
+ }
+
+ /// Performs an HTTP 'DELETE /notifications' operation and returns the [Response].
+ /// Parameters:
+ ///
+ /// * [NotificationDeleteAllDto] notificationDeleteAllDto (required):
+ Future deleteNotificationsWithHttpInfo(NotificationDeleteAllDto notificationDeleteAllDto,) async {
+ // ignore: prefer_const_declarations
+ final apiPath = r'/notifications';
+
+ // ignore: prefer_final_locals
+ Object? postBody = notificationDeleteAllDto;
+
+ final queryParams = [];
+ final headerParams = {};
+ final formParams = {};
+
+ const contentTypes = ['application/json'];
+
+
+ return apiClient.invokeAPI(
+ apiPath,
+ 'DELETE',
+ queryParams,
+ postBody,
+ headerParams,
+ formParams,
+ contentTypes.isEmpty ? null : contentTypes.first,
+ );
+ }
+
+ /// Parameters:
+ ///
+ /// * [NotificationDeleteAllDto] notificationDeleteAllDto (required):
+ Future deleteNotifications(NotificationDeleteAllDto notificationDeleteAllDto,) async {
+ final response = await deleteNotificationsWithHttpInfo(notificationDeleteAllDto,);
+ if (response.statusCode >= HttpStatus.badRequest) {
+ throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+ }
+ }
+
+ /// Performs an HTTP 'GET /notifications/{id}' operation and returns the [Response].
+ /// Parameters:
+ ///
+ /// * [String] id (required):
+ Future getNotificationWithHttpInfo(String id,) async {
+ // ignore: prefer_const_declarations
+ final apiPath = r'/notifications/{id}'
+ .replaceAll('{id}', id);
+
+ // ignore: prefer_final_locals
+ Object? postBody;
+
+ final queryParams = [];
+ final headerParams = {};
+ final formParams = {};
+
+ const contentTypes = [];
+
+
+ return apiClient.invokeAPI(
+ apiPath,
+ 'GET',
+ queryParams,
+ postBody,
+ headerParams,
+ formParams,
+ contentTypes.isEmpty ? null : contentTypes.first,
+ );
+ }
+
+ /// Parameters:
+ ///
+ /// * [String] id (required):
+ Future getNotification(String id,) async {
+ final response = await getNotificationWithHttpInfo(id,);
+ if (response.statusCode >= HttpStatus.badRequest) {
+ throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+ }
+ // When a remote server returns no body with a status of 204, we shall not decode it.
+ // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+ // FormatException when trying to decode an empty string.
+ if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+ return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'NotificationDto',) as NotificationDto;
+
+ }
+ return null;
+ }
+
+ /// Performs an HTTP 'GET /notifications' operation and returns the [Response].
+ /// Parameters:
+ ///
+ /// * [String] id:
+ ///
+ /// * [NotificationLevel] level:
+ ///
+ /// * [NotificationType] type:
+ ///
+ /// * [bool] unread:
+ Future getNotificationsWithHttpInfo({ String? id, NotificationLevel? level, NotificationType? type, bool? unread, }) async {
+ // ignore: prefer_const_declarations
+ final apiPath = r'/notifications';
+
+ // ignore: prefer_final_locals
+ Object? postBody;
+
+ final queryParams = [];
+ final headerParams = {};
+ final formParams = {};
+
+ if (id != null) {
+ queryParams.addAll(_queryParams('', 'id', id));
+ }
+ if (level != null) {
+ queryParams.addAll(_queryParams('', 'level', level));
+ }
+ if (type != null) {
+ queryParams.addAll(_queryParams('', 'type', type));
+ }
+ if (unread != null) {
+ queryParams.addAll(_queryParams('', 'unread', unread));
+ }
+
+ const contentTypes = [];
+
+
+ return apiClient.invokeAPI(
+ apiPath,
+ 'GET',
+ queryParams,
+ postBody,
+ headerParams,
+ formParams,
+ contentTypes.isEmpty ? null : contentTypes.first,
+ );
+ }
+
+ /// Parameters:
+ ///
+ /// * [String] id:
+ ///
+ /// * [NotificationLevel] level:
+ ///
+ /// * [NotificationType] type:
+ ///
+ /// * [bool] unread:
+ Future?> getNotifications({ String? id, NotificationLevel? level, NotificationType? type, bool? unread, }) async {
+ final response = await getNotificationsWithHttpInfo( id: id, level: level, type: type, unread: unread, );
+ if (response.statusCode >= HttpStatus.badRequest) {
+ throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+ }
+ // When a remote server returns no body with a status of 204, we shall not decode it.
+ // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+ // FormatException when trying to decode an empty string.
+ if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+ final responseBody = await _decodeBodyBytes(response);
+ return (await apiClient.deserializeAsync(responseBody, 'List') as List)
+ .cast()
+ .toList(growable: false);
+
+ }
+ return null;
+ }
+
+ /// Performs an HTTP 'PUT /notifications/{id}' operation and returns the [Response].
+ /// Parameters:
+ ///
+ /// * [String] id (required):
+ ///
+ /// * [NotificationUpdateDto] notificationUpdateDto (required):
+ Future updateNotificationWithHttpInfo(String id, NotificationUpdateDto notificationUpdateDto,) async {
+ // ignore: prefer_const_declarations
+ final apiPath = r'/notifications/{id}'
+ .replaceAll('{id}', id);
+
+ // ignore: prefer_final_locals
+ Object? postBody = notificationUpdateDto;
+
+ final queryParams = [];
+ final headerParams = {};
+ final formParams = {};
+
+ const contentTypes = ['application/json'];
+
+
+ return apiClient.invokeAPI(
+ apiPath,
+ 'PUT',
+ queryParams,
+ postBody,
+ headerParams,
+ formParams,
+ contentTypes.isEmpty ? null : contentTypes.first,
+ );
+ }
+
+ /// Parameters:
+ ///
+ /// * [String] id (required):
+ ///
+ /// * [NotificationUpdateDto] notificationUpdateDto (required):
+ Future updateNotification(String id, NotificationUpdateDto notificationUpdateDto,) async {
+ final response = await updateNotificationWithHttpInfo(id, notificationUpdateDto,);
+ if (response.statusCode >= HttpStatus.badRequest) {
+ throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+ }
+ // When a remote server returns no body with a status of 204, we shall not decode it.
+ // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+ // FormatException when trying to decode an empty string.
+ if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+ return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'NotificationDto',) as NotificationDto;
+
+ }
+ return null;
+ }
+
+ /// Performs an HTTP 'PUT /notifications' operation and returns the [Response].
+ /// Parameters:
+ ///
+ /// * [NotificationUpdateAllDto] notificationUpdateAllDto (required):
+ Future updateNotificationsWithHttpInfo(NotificationUpdateAllDto notificationUpdateAllDto,) async {
+ // ignore: prefer_const_declarations
+ final apiPath = r'/notifications';
+
+ // ignore: prefer_final_locals
+ Object? postBody = notificationUpdateAllDto;
+
+ final queryParams = [];
+ final headerParams = {};
+ final formParams = {};
+
+ const contentTypes = ['application/json'];
+
+
+ return apiClient.invokeAPI(
+ apiPath,
+ 'PUT',
+ queryParams,
+ postBody,
+ headerParams,
+ formParams,
+ contentTypes.isEmpty ? null : contentTypes.first,
+ );
+ }
+
+ /// Parameters:
+ ///
+ /// * [NotificationUpdateAllDto] notificationUpdateAllDto (required):
+ Future updateNotifications(NotificationUpdateAllDto notificationUpdateAllDto,) async {
+ final response = await updateNotificationsWithHttpInfo(notificationUpdateAllDto,);
+ if (response.statusCode >= HttpStatus.badRequest) {
+ throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+ }
+ }
+}
diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart
index 5759217f41..7586cc1ae2 100644
--- a/mobile/openapi/lib/api_client.dart
+++ b/mobile/openapi/lib/api_client.dart
@@ -270,8 +270,6 @@ class ApiClient {
return AssetTypeEnumTypeTransformer().decode(value);
case 'AudioCodec':
return AudioCodecTypeTransformer().decode(value);
- case 'AvatarResponse':
- return AvatarResponse.fromJson(value);
case 'AvatarUpdate':
return AvatarUpdate.fromJson(value);
case 'BulkIdResponseDto':
@@ -392,6 +390,20 @@ class ApiClient {
return MergePersonDto.fromJson(value);
case 'MetadataSearchDto':
return MetadataSearchDto.fromJson(value);
+ case 'NotificationCreateDto':
+ return NotificationCreateDto.fromJson(value);
+ case 'NotificationDeleteAllDto':
+ return NotificationDeleteAllDto.fromJson(value);
+ case 'NotificationDto':
+ return NotificationDto.fromJson(value);
+ case 'NotificationLevel':
+ return NotificationLevelTypeTransformer().decode(value);
+ case 'NotificationType':
+ return NotificationTypeTypeTransformer().decode(value);
+ case 'NotificationUpdateAllDto':
+ return NotificationUpdateAllDto.fromJson(value);
+ case 'NotificationUpdateDto':
+ return NotificationUpdateDto.fromJson(value);
case 'OAuthAuthorizeResponseDto':
return OAuthAuthorizeResponseDto.fromJson(value);
case 'OAuthCallbackDto':
diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart
index 1ebf8314ad..cc517d48ab 100644
--- a/mobile/openapi/lib/api_helper.dart
+++ b/mobile/openapi/lib/api_helper.dart
@@ -100,6 +100,12 @@ String parameterToString(dynamic value) {
if (value is MemoryType) {
return MemoryTypeTypeTransformer().encode(value).toString();
}
+ if (value is NotificationLevel) {
+ return NotificationLevelTypeTransformer().encode(value).toString();
+ }
+ if (value is NotificationType) {
+ return NotificationTypeTypeTransformer().encode(value).toString();
+ }
if (value is PartnerDirection) {
return PartnerDirectionTypeTransformer().encode(value).toString();
}
diff --git a/mobile/openapi/lib/model/notification_create_dto.dart b/mobile/openapi/lib/model/notification_create_dto.dart
new file mode 100644
index 0000000000..07985353b2
--- /dev/null
+++ b/mobile/openapi/lib/model/notification_create_dto.dart
@@ -0,0 +1,180 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.18
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class NotificationCreateDto {
+ /// Returns a new [NotificationCreateDto] instance.
+ NotificationCreateDto({
+ this.data,
+ this.description,
+ this.level,
+ this.readAt,
+ required this.title,
+ this.type,
+ required this.userId,
+ });
+
+ ///
+ /// Please note: This property should have been non-nullable! Since the specification file
+ /// does not include a default value (using the "default:" property), however, the generated
+ /// source code must fall back to having a nullable type.
+ /// Consider adding a "default:" property in the specification file to hide this note.
+ ///
+ Object? data;
+
+ String? description;
+
+ ///
+ /// Please note: This property should have been non-nullable! Since the specification file
+ /// does not include a default value (using the "default:" property), however, the generated
+ /// source code must fall back to having a nullable type.
+ /// Consider adding a "default:" property in the specification file to hide this note.
+ ///
+ NotificationLevel? level;
+
+ DateTime? readAt;
+
+ String title;
+
+ ///
+ /// Please note: This property should have been non-nullable! Since the specification file
+ /// does not include a default value (using the "default:" property), however, the generated
+ /// source code must fall back to having a nullable type.
+ /// Consider adding a "default:" property in the specification file to hide this note.
+ ///
+ NotificationType? type;
+
+ String userId;
+
+ @override
+ bool operator ==(Object other) => identical(this, other) || other is NotificationCreateDto &&
+ other.data == data &&
+ other.description == description &&
+ other.level == level &&
+ other.readAt == readAt &&
+ other.title == title &&
+ other.type == type &&
+ other.userId == userId;
+
+ @override
+ int get hashCode =>
+ // ignore: unnecessary_parenthesis
+ (data == null ? 0 : data!.hashCode) +
+ (description == null ? 0 : description!.hashCode) +
+ (level == null ? 0 : level!.hashCode) +
+ (readAt == null ? 0 : readAt!.hashCode) +
+ (title.hashCode) +
+ (type == null ? 0 : type!.hashCode) +
+ (userId.hashCode);
+
+ @override
+ String toString() => 'NotificationCreateDto[data=$data, description=$description, level=$level, readAt=$readAt, title=$title, type=$type, userId=$userId]';
+
+ Map toJson() {
+ final json = {};
+ if (this.data != null) {
+ json[r'data'] = this.data;
+ } else {
+ // json[r'data'] = null;
+ }
+ if (this.description != null) {
+ json[r'description'] = this.description;
+ } else {
+ // json[r'description'] = null;
+ }
+ if (this.level != null) {
+ json[r'level'] = this.level;
+ } else {
+ // json[r'level'] = null;
+ }
+ if (this.readAt != null) {
+ json[r'readAt'] = this.readAt!.toUtc().toIso8601String();
+ } else {
+ // json[r'readAt'] = null;
+ }
+ json[r'title'] = this.title;
+ if (this.type != null) {
+ json[r'type'] = this.type;
+ } else {
+ // json[r'type'] = null;
+ }
+ json[r'userId'] = this.userId;
+ return json;
+ }
+
+ /// Returns a new [NotificationCreateDto] instance and imports its values from
+ /// [value] if it's a [Map], null otherwise.
+ // ignore: prefer_constructors_over_static_methods
+ static NotificationCreateDto? fromJson(dynamic value) {
+ upgradeDto(value, "NotificationCreateDto");
+ if (value is Map) {
+ final json = value.cast();
+
+ return NotificationCreateDto(
+ data: mapValueOfType