Compare commits

..

1 Commits

Author SHA1 Message Date
shenlong-tanwen 0c9263b69f feat: show notification and battery optimization warning 2026-05-13 03:39:15 +05:30
82 changed files with 974 additions and 955 deletions
+3
View File
@@ -116,6 +116,7 @@ jobs:
~/.gradle/wrapper
~/.android/sdk
mobile/android/.gradle
mobile/.dart_tool
key: build-mobile-gradle-${{ runner.os }}-main
- name: Setup Android SDK
@@ -188,6 +189,7 @@ jobs:
~/.gradle/wrapper
~/.android/sdk
mobile/android/.gradle
mobile/.dart_tool
key: ${{ steps.cache-gradle-restore.outputs.cache-primary-key }}
build-sign-ios:
@@ -288,6 +290,7 @@ jobs:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
ENVIRONMENT: ${{ inputs.environment || 'development' }}
BUNDLE_ID_SUFFIX: ${{ inputs.environment == 'production' && '' || 'development' }}
GITHUB_REF: ${{ github.ref }}
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 120
FASTLANE_XCODEBUILD_SETTINGS_RETRIES: 6
+1 -1
View File
@@ -565,7 +565,7 @@ jobs:
run: mise //mobile:codegen:translation
- name: Run tests
run: mise //mobile:test
run: mise //mobile:test -j 1
ml-unit-tests:
name: Unit Test ML
+30 -30
View File
@@ -2,37 +2,37 @@
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.7"
constraints = "4.52.7"
version = "4.52.5"
constraints = "4.52.5"
hashes = [
"h1:+O72J3QYiZtYmYYZM/Eh0f4NNfl1BvjX1eju43qTQsQ=",
"h1:0oqjYIPXcXh7XiDiKI085cHDYQQ5mh8kDl9dmBtvtog=",
"h1:4b4ESb87MGv5bnadgYe7sK5rEkKMZhbkQcwPubQTsR4=",
"h1:6mTr3eA1Ddb348lLmJuyvn98z4KF+ejqaUEJ76D1rzQ=",
"h1:9/3YH+9k9HqsvFtbmBf7SO2+xqZeZrXNKzLkjNuhUEA=",
"h1:Jcq4tBWgyH4/2JsojNBSRaN0mcItVMchO+lynonrlqc=",
"h1:Y4Vv/2RdP0Q+uxqhOxzOdKxuuEMjXPDcU0vPc5bCQzI=",
"h1:a0gW8FBKsbP9Fi0HEDoy49WIbEWVHk9+BR4/iwuBdDQ=",
"h1:gElv6iqJtg8OKN77gbw+MjrkrQmJHPkkMEi1J+0xkpU=",
"h1:oslXUugD/NQ+duJgT4BhKQyfGbuFOANknMvR73fiOeM=",
"h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=",
"h1:u67GWw8GwD9NDlDzp9Y5VRnSQGcCrE8rSpkGPaBpDl0=",
"h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=",
"h1:y3rV8KF2q6GEMANNlf5EkKJurlfbKlIKpjGcdxoy7pQ=",
"zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b",
"zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e",
"zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10",
"zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285",
"zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529",
"h1:+rfzF+16ZcWZWnTyW/p1HHTzYbPKX8Zt2nIFtR/+f+E=",
"h1:18bXaaOSq8MWKuMxo/4y7EB7/i7G90y5QsKHZRmkoDo=",
"h1:4vZVOpKeEQZsF2VrARRZFeL37Ed/gD4rRMtfnvWQres=",
"h1:BZOsTF83QPKXTAaYqxPKzdl1KRjk/L2qbPpFjM0w28A=",
"h1:CDuC+HXLvc1z6wkCRsSDcc/+QENIHEtssYshiWg3opA=",
"h1:DE+YFzLnqSe79pI2R4idRGx5QzLdrA7RXvngTkGfZ30=",
"h1:DfaJwH3Ml4yrRbdAY4AcDVy0QTQk5T3A622TXzS/u2E=",
"h1:EIDXP0W3kgIv2pecrFmqtK/DnlqkyckzBzhxKaXU+4A=",
"h1:EV4kYyaOnwGA0bh/3hU6Ezqnt1PFDxopH7i85e48IzY=",
"h1:M0iXabfzamU+MPDi0G9XACpbacFKMakmM+Z9HZ8HrsM=",
"h1:YWmCbGF/KbsrUzcYVBLscwLizidbp95TDQa0N2qpmVo=",
"h1:cxPcCB5gbrpUO1+IXkQYs1YTY50/0IlApCzGea0cwuQ=",
"h1:g6DldikTV2HXUu9uoeNY5FuLufgaYWF4ufgZg7wq62s=",
"h1:oi/Hrx9pwoQ+Z52CBC+rrowVH387EIj0qvnxQgDeI+0=",
"zh:1a3400cb38863b2585968d1876706bcfc67a148e1318a1d325c6c7704adc999b",
"zh:4c5062cb9e9da1676f06ae92b8370186d98976cc4c7030d3cd76df12af54282a",
"zh:52110f493b5f0587ef77a1cfd1a67001fd4c617b14c6502d732ab47352bdc2f7",
"zh:5aa536f9eaeb43823aaf2aa80e7d39b25ef2b383405ed034aa16a28b446a9238",
"zh:5cc39459a1c6be8a918f17054e4fbba573825ed5597dcada588fe99614d98a5b",
"zh:629ae6a7ba298815131da826474d199312d21cec53a4d5ded4fa56a692e6f072",
"zh:719cc7c75dc1d3eb30c22ff5102a017996d9788b948078c7e1c5b3446aeca661",
"zh:8698635a3ca04383c1e93b21d6963346bdae54d27177a48e4b1435b7f731731c",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13",
"zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d",
"zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f",
"zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d",
"zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe",
"zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455",
"zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2",
"zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b",
"zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe",
"zh:8a9993f1dcadf1dd6ca43b23348abe374605d29945a2fafc07fb3457644e6a54",
"zh:b1b9a1e6bcc24d5863a664a411d2dc906373ae7a2399d2d65548ce7377057852",
"zh:b270184cdeec277218e84b94cb136fead753da717f9b9dc378e51907f3f00bb0",
"zh:dff2bc10071210181726ce270f954995fe42c696e61e2e8f874021fed02521e5",
"zh:e8e87b40b6a87dc097b0fdc20d3f725cec0d82abc9cc3755c1f89f8f6e8b0036",
"zh:ee964a6573d399a5dd22ce328fb38ca1207797a02248f14b2e4913ee390e7803",
]
}
@@ -5,7 +5,7 @@ terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "4.52.7"
version = "4.52.5"
}
}
}
+30 -30
View File
@@ -2,37 +2,37 @@
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.7"
constraints = "4.52.7"
version = "4.52.5"
constraints = "4.52.5"
hashes = [
"h1:+O72J3QYiZtYmYYZM/Eh0f4NNfl1BvjX1eju43qTQsQ=",
"h1:0oqjYIPXcXh7XiDiKI085cHDYQQ5mh8kDl9dmBtvtog=",
"h1:4b4ESb87MGv5bnadgYe7sK5rEkKMZhbkQcwPubQTsR4=",
"h1:6mTr3eA1Ddb348lLmJuyvn98z4KF+ejqaUEJ76D1rzQ=",
"h1:9/3YH+9k9HqsvFtbmBf7SO2+xqZeZrXNKzLkjNuhUEA=",
"h1:Jcq4tBWgyH4/2JsojNBSRaN0mcItVMchO+lynonrlqc=",
"h1:Y4Vv/2RdP0Q+uxqhOxzOdKxuuEMjXPDcU0vPc5bCQzI=",
"h1:a0gW8FBKsbP9Fi0HEDoy49WIbEWVHk9+BR4/iwuBdDQ=",
"h1:gElv6iqJtg8OKN77gbw+MjrkrQmJHPkkMEi1J+0xkpU=",
"h1:oslXUugD/NQ+duJgT4BhKQyfGbuFOANknMvR73fiOeM=",
"h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=",
"h1:u67GWw8GwD9NDlDzp9Y5VRnSQGcCrE8rSpkGPaBpDl0=",
"h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=",
"h1:y3rV8KF2q6GEMANNlf5EkKJurlfbKlIKpjGcdxoy7pQ=",
"zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b",
"zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e",
"zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10",
"zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285",
"zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529",
"h1:+rfzF+16ZcWZWnTyW/p1HHTzYbPKX8Zt2nIFtR/+f+E=",
"h1:18bXaaOSq8MWKuMxo/4y7EB7/i7G90y5QsKHZRmkoDo=",
"h1:4vZVOpKeEQZsF2VrARRZFeL37Ed/gD4rRMtfnvWQres=",
"h1:BZOsTF83QPKXTAaYqxPKzdl1KRjk/L2qbPpFjM0w28A=",
"h1:CDuC+HXLvc1z6wkCRsSDcc/+QENIHEtssYshiWg3opA=",
"h1:DE+YFzLnqSe79pI2R4idRGx5QzLdrA7RXvngTkGfZ30=",
"h1:DfaJwH3Ml4yrRbdAY4AcDVy0QTQk5T3A622TXzS/u2E=",
"h1:EIDXP0W3kgIv2pecrFmqtK/DnlqkyckzBzhxKaXU+4A=",
"h1:EV4kYyaOnwGA0bh/3hU6Ezqnt1PFDxopH7i85e48IzY=",
"h1:M0iXabfzamU+MPDi0G9XACpbacFKMakmM+Z9HZ8HrsM=",
"h1:YWmCbGF/KbsrUzcYVBLscwLizidbp95TDQa0N2qpmVo=",
"h1:cxPcCB5gbrpUO1+IXkQYs1YTY50/0IlApCzGea0cwuQ=",
"h1:g6DldikTV2HXUu9uoeNY5FuLufgaYWF4ufgZg7wq62s=",
"h1:oi/Hrx9pwoQ+Z52CBC+rrowVH387EIj0qvnxQgDeI+0=",
"zh:1a3400cb38863b2585968d1876706bcfc67a148e1318a1d325c6c7704adc999b",
"zh:4c5062cb9e9da1676f06ae92b8370186d98976cc4c7030d3cd76df12af54282a",
"zh:52110f493b5f0587ef77a1cfd1a67001fd4c617b14c6502d732ab47352bdc2f7",
"zh:5aa536f9eaeb43823aaf2aa80e7d39b25ef2b383405ed034aa16a28b446a9238",
"zh:5cc39459a1c6be8a918f17054e4fbba573825ed5597dcada588fe99614d98a5b",
"zh:629ae6a7ba298815131da826474d199312d21cec53a4d5ded4fa56a692e6f072",
"zh:719cc7c75dc1d3eb30c22ff5102a017996d9788b948078c7e1c5b3446aeca661",
"zh:8698635a3ca04383c1e93b21d6963346bdae54d27177a48e4b1435b7f731731c",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13",
"zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d",
"zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f",
"zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d",
"zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe",
"zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455",
"zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2",
"zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b",
"zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe",
"zh:8a9993f1dcadf1dd6ca43b23348abe374605d29945a2fafc07fb3457644e6a54",
"zh:b1b9a1e6bcc24d5863a664a411d2dc906373ae7a2399d2d65548ce7377057852",
"zh:b270184cdeec277218e84b94cb136fead753da717f9b9dc378e51907f3f00bb0",
"zh:dff2bc10071210181726ce270f954995fe42c696e61e2e8f874021fed02521e5",
"zh:e8e87b40b6a87dc097b0fdc20d3f725cec0d82abc9cc3755c1f89f8f6e8b0036",
"zh:ee964a6573d399a5dd22ce328fb38ca1207797a02248f14b2e4913ee390e7803",
]
}
+1 -1
View File
@@ -5,7 +5,7 @@ terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "4.52.7"
version = "4.52.5"
}
}
}
@@ -13,11 +13,8 @@ The `immich-server` docker image comes preinstalled with an administrative CLI (
| `enable-oauth-login` | Enable OAuth login |
| `disable-oauth-login` | Disable OAuth login |
| `list-users` | List Immich users |
| `grant-admin` | Grant admin privileges to a user (by email) |
| `revoke-admin` | Revoke admin privileges from a user (by email) |
| `version` | Print Immich version |
| `change-media-location` | Change database file paths to align with a new media location |
| `schema-check` | Verify database migrations and check for schema drift |
## How to run a command
@@ -105,22 +102,6 @@ immich-admin list-users
]
```
Grant Admin
```
immich-admin grant-admin
? Please enter the user email: user@example.com
Admin access has been granted to user@example.com
```
Revoke Admin
```
immich-admin revoke-admin
? Please enter the user email: user@example.com
Admin access has been revoked from user@example.com
```
Print Immich Version
```
@@ -145,12 +126,3 @@ immich-admin change-media-location
Database file paths updated successfully! 🎉
...
```
Schema Check
```
immich-admin schema-check
Migrations are up to date
No schema drift detected
```
+2 -66
View File
@@ -52,7 +52,7 @@ Scroll to the bottom of the "**Details**" section and find the `IP Address` list
## Step 4 - Configure Firewall Settings
Once your project completes the build process, your containers will start. In order to be able to access Immich from your browser, you need to configure the firewall settings for your Synology NAS to allow communication between the Immich containers.
Once your project completes the build process, your containers will start. In order to be able to access Immich from your browser, you need to configure the firewall settings for your Synology NAS.
Open "**Control Panel**" on your Synology NAS, and select "**Security**". Navigate to "**Firewall**"
@@ -74,7 +74,6 @@ Read the [Post Installation](/install/post-install.mdx) steps and [upgrade instr
<details>
<summary>Updating Immich using Container Manager</summary>
Check the post installation and upgrade instructions at the links above before proceeding with this section.
## Step 1. Backup
@@ -111,7 +110,7 @@ Go to **Project**, select **Action** then **Build**. This will download, unpack,
## Step 5. Update firewall rule
Without a fixed subnet, the default behavior is to automatically start the containers once installed. If `immich_server` runs for a few seconds and then stops, it may be because the firewall rule no longer matches the server IP address.
The default behavior is to automatically start the containers once installed. If `immich_server` runs for a few seconds and then stops, it may be because the firewall rule no longer matches the server IP address.
Go to the **Container** section. Click on `immich_server` and scroll down on **General** to find the IP address.
![Container IP](../../static/img/synology-container-ip.png)
@@ -124,67 +123,4 @@ In this example, the IP addresses mismatch and the firewall rule needs to be edi
![Edit IP](../../static/img/synology-fw-ipedit.png)
To prevent future firewall issues, you may set a fixed subnet. [See Set Fixed Subnet](#set-fixed-subnet) for instructions.
</details>
<details id="set-fixed-subnet">
<summary>Set Fixed Subnet</summary>
Docker by default assigns dynamic subnets to bridge networks which can change when rebuilding containers and can cause firewall rules to break. To avoid this, define a fixed subnet in your `docker-compose.yml`:
## Step 1. Determine current subnet
Go to the **Container** section. Click on `immich_server` and scroll down on **General** to find the IP address.
![Container IP](../../static/img/synology-container-ip.png)
## Step 2. Add network configuration
Add the following network configuration at the end of your `docker-compose.yml` file:
```yaml
networks:
immich-network:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
gateway: 172.20.0.1
```
If your docker container is running on a different subnet then update accordingly.
## Step 3. Add network to each service
Add the network to each service (immich-server, immich-machine-learning, redis, database):
```yaml
services:
immich-server:
# other config options
networks:
- immich-network
immich-machine-learning:
# other config options
networks:
- immich-network
redis:
# other config options
networks:
- immich-network
database:
# other config options
networks:
- immich-network
```
Save your changes. Synology will ask if you want to save changes only or rebuild containers. Select rebuild containers.
## Step 4. Update Firewall Rules, if necessary
If your firewall rules were not already set for this subnet, the firewall rules will need to be updated. See [Step 4 - Configure Firewall Settings](#step-4---configure-firewall-settings).
</details>
@@ -2,7 +2,7 @@ import { LoginResponseDto, ManualJobName } from '@immich/sdk';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
describe('/admin/database-backups', () => {
let cookie: string | undefined;
@@ -13,9 +13,6 @@ describe('/admin/database-backups', () => {
admin = await utils.adminSetup({
onboarding: false,
});
});
beforeEach(async () => {
await utils.resetBackups(admin.accessToken);
});
-2
View File
@@ -568,8 +568,6 @@ export const utils = {
name: ManualJobName.BackupDatabase,
});
await utils.waitForQueueFinish(accessToken, 'backupDatabase');
return utils.poll(
() => request(app).get('/admin/database-backups').set('Authorization', `Bearer ${accessToken}`),
({ status, body }) => status === 200 && body.backups.length === 1,
+6 -3
View File
@@ -693,6 +693,7 @@
"backup_settings_subtitle": "Manage upload settings",
"backup_upload_details_page_more_details": "Tap for more details",
"backward": "Backward",
"battery_optimization_backup_reliability": "Disabling battery optimizations can improve the reliability of background backup",
"biometric_auth_enabled": "Biometric authentication enabled",
"biometric_locked_out": "You are locked out of biometric authentication",
"biometric_no_options": "No biometric options available",
@@ -885,13 +886,15 @@
"cutoff_date_description": "Keep photos from the last…",
"cutoff_day": "{count, plural, one {day} other {days}}",
"cutoff_year": "{count, plural, one {year} other {years}}",
"daily_title_text_date": "E, MMM dd",
"daily_title_text_date_year": "E, MMM dd, yyyy",
"dark": "Dark",
"dark_theme": "Switch to dark theme",
"date": "Date",
"date_after": "Date after",
"date_and_time": "Date and Time",
"date_before": "Date before",
"date_of_birth": "Date of birth",
"date_format": "E, LLL d, y • h:mm a",
"date_of_birth_saved": "Date of birth saved successfully",
"date_range": "Date range",
"day": "Day",
@@ -1401,7 +1404,6 @@
"link_to_oauth": "Link to OAuth",
"linked_oauth_account": "Linked OAuth account",
"list": "List",
"live": "Live",
"loading": "Loading",
"loading_search_results_failed": "Loading search results failed",
"local": "Local",
@@ -1581,8 +1583,8 @@
"mobile_app_download_onboarding_note": "Download the companion mobile app using the following options",
"model": "Model",
"month": "Month",
"monthly_title_text_date_format": "MMMM y",
"more": "More",
"motion": "Motion",
"move": "Move",
"move_down": "Move down",
"move_off_locked_folder": "Move out of locked folder",
@@ -1666,6 +1668,7 @@
"not_selected": "Not selected",
"notes": "Notes",
"nothing_here_yet": "Nothing here yet",
"notification_backup_reliability": "Enable notifications to improve background backup reliability",
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
"notification_permission_list_tile_content": "Grant permission to enable notifications.",
"notification_permission_list_tile_enable_button": "Enable Notifications",
@@ -17,6 +17,8 @@ import app.alextran.immich.images.LocalImageApi
import app.alextran.immich.images.LocalImagesImpl
import app.alextran.immich.images.RemoteImageApi
import app.alextran.immich.images.RemoteImagesImpl
import app.alextran.immich.permission.PermissionApi
import app.alextran.immich.permission.PermissionApiImpl
import app.alextran.immich.sync.NativeSyncApi
import app.alextran.immich.sync.NativeSyncApiImpl26
import app.alextran.immich.sync.NativeSyncApiImpl30
@@ -50,6 +52,7 @@ class MainActivity : FlutterFragmentActivity() {
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
PermissionApi.setUp(messenger, PermissionApiImpl(ctx))
flutterEngine.plugins.add(backgroundEngineLockImpl)
flutterEngine.plugins.add(nativeSyncApiImpl)
@@ -1,6 +1,7 @@
package app.alextran.immich.core
import android.app.Activity
import android.content.Context
import android.os.OperationCanceledException
import android.security.KeyChain
import app.alextran.immich.NativeBuffer
@@ -0,0 +1,114 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
package app.alextran.immich.permission
import android.util.Log
import io.flutter.plugin.common.BasicMessageChannel
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MessageCodec
import io.flutter.plugin.common.StandardMethodCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private object PermissionApiPigeonUtils {
fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
}
fun wrapError(exception: Throwable): List<Any?> {
return if (exception is FlutterError) {
listOf(
exception.code,
exception.message,
exception.details
)
} else {
listOf(
exception.javaClass.simpleName,
exception.toString(),
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
)
}
}
}
/**
* Error class for passing custom error details to Flutter via a thrown PlatformException.
* @property code The error code.
* @property message The error message.
* @property details The error details. Must be a datatype supported by the api codec.
*/
class FlutterError (
val code: String,
override val message: String? = null,
val details: Any? = null
) : RuntimeException()
enum class PermissionStatus(val raw: Int) {
GRANTED(0),
DENIED(1),
PERMANENTLY_DENIED(2);
companion object {
fun ofRaw(raw: Int): PermissionStatus? {
return values().firstOrNull { it.raw == raw }
}
}
}
private open class PermissionApiPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
129.toByte() -> {
return (readValue(buffer) as Long?)?.let {
PermissionStatus.ofRaw(it.toInt())
}
}
else -> super.readValueOfType(type, buffer)
}
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) {
is PermissionStatus -> {
stream.write(129)
writeValue(stream, value.raw.toLong())
}
else -> super.writeValue(stream, value)
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface PermissionApi {
fun isIgnoringBatteryOptimizations(): PermissionStatus
companion object {
/** The codec used by PermissionApi. */
val codec: MessageCodec<Any?> by lazy {
PermissionApiPigeonCodec()
}
/** Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.isIgnoringBatteryOptimizations$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.isIgnoringBatteryOptimizations())
} catch (exception: Throwable) {
PermissionApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
@@ -0,0 +1,19 @@
package app.alextran.immich.permission
import android.content.Context
import android.os.PowerManager
class PermissionApiImpl(context: Context) : PermissionApi {
private val ctx: Context = context.applicationContext
private val powerManager =
ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
override fun isIgnoringBatteryOptimizations(): PermissionStatus {
if (powerManager.isIgnoringBatteryOptimizations(ctx.packageName)) {
return PermissionStatus.GRANTED
}
return PermissionStatus.DENIED
}
}
+6 -3
View File
@@ -718,7 +718,6 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
CUSTOM_GROUP_ID = group.app.immich.share.profile;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -751,6 +750,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -801,7 +801,6 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
CUSTOM_GROUP_ID = group.app.immich.share.debug;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
@@ -861,7 +860,6 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
CUSTOM_GROUP_ID = group.app.immich.share;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -896,6 +894,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -925,6 +924,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -1080,6 +1080,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1123,6 +1124,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1163,6 +1165,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
-3
View File
@@ -31,9 +31,6 @@ import native_video_player
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: messenger, api: BackgroundWorkerApiImpl())
ConnectivityApiSetup.setUp(binaryMessenger: messenger, api: ConnectivityApiImpl())
NetworkApiSetup.setUp(binaryMessenger: messenger, api: NetworkApiImpl())
FlutterMethodChannel(name: "home_widget", binaryMessenger: messenger)
.invokeMethod("setAppGroupId", arguments: ["groupId": APP_GROUP])
}
public static func cancelPlugins(with engine: FlutterEngine) {
@@ -4,7 +4,7 @@ import native_video_player
let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity"
let HEADERS_KEY = "immich.request_headers"
let SERVER_URLS_KEY = "immich.server_urls"
let APP_GROUP = Bundle.main.object(forInfoDictionaryKey: "AppGroupId") as! String
let APP_GROUP = "group.app.immich.share"
let COOKIE_EXPIRY_DAYS: TimeInterval = 400
enum AuthCookie: CaseIterable {
+1 -1
View File
@@ -10,7 +10,7 @@
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>$(CUSTOM_GROUP_ID)</string>
<string>group.app.immich.share</string>
</array>
</dict>
</plist>
+1 -1
View File
@@ -12,7 +12,7 @@
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>$(CUSTOM_GROUP_ID)</string>
<string>group.app.immich.share</string>
</array>
</dict>
</plist>
@@ -4,7 +4,7 @@
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>$(CUSTOM_GROUP_ID)</string>
<string>group.app.immich.share</string>
</array>
</dict>
</plist>
+1 -1
View File
@@ -2,7 +2,7 @@ import Foundation
import SwiftUI
import WidgetKit
let IMMICH_SHARE_GROUP = Bundle.main.object(forInfoDictionaryKey: "AppGroupId") as! String
let IMMICH_SHARE_GROUP = "group.app.immich.share"
enum WidgetError: Error, Codable {
case noLogin
-2
View File
@@ -2,8 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
@@ -4,7 +4,7 @@
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>$(CUSTOM_GROUP_ID)</string>
<string>group.app.immich.share</string>
</array>
</dict>
</plist>
+6 -16
View File
@@ -21,7 +21,6 @@ platform :ios do
CODE_SIGN_IDENTITY = "Apple Distribution: FUTO Holdings, Inc. (#{TEAM_ID})"
BASE_BUNDLE_ID = "app.alextran.immich"
DEV_BUNDLE_ID = "tech.futo.immich.testflight"
DEV_GROUP_ID = "group.app.immich.share.testflight"
# Helper method to get App Store Connect API key
def get_api_key
@@ -34,13 +33,6 @@ platform :ios do
)
end
# Helper method to assemble xcargs with optional CUSTOM_GROUP_ID override
def build_xcargs(group_id: nil)
args = "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual"
args += " CUSTOM_GROUP_ID='#{group_id}'" if group_id
args
end
# Helper method to get version from pubspec.yaml
def get_version_from_pubspec
require 'yaml'
@@ -97,8 +89,7 @@ end
version_number: nil,
profile_name_main:,
profile_name_share:,
profile_name_widget:,
group_id: nil
profile_name_widget:
)
app_identifier = base_bundle_id
@@ -106,7 +97,7 @@ end
if version_number
increment_version_number(version_number: version_number)
end
# Increment build number
increment_build_number(
build_number: latest_testflight_build_number(
@@ -115,14 +106,14 @@ end
) + 1,
xcodeproj: "./Runner.xcodeproj"
)
# Build the app
build_app(
scheme: "Runner",
workspace: "Runner.xcworkspace",
configuration: configuration,
export_method: "app-store",
xcargs: build_xcargs(group_id: group_id),
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
export_options: {
provisioningProfiles: {
"#{app_identifier}" => profile_name_main,
@@ -174,8 +165,7 @@ end
distribute_external: false,
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name,
group_id: DEV_GROUP_ID
profile_name_widget: widget_profile_name
)
end
@@ -284,7 +274,7 @@ end
configuration: "Release",
export_method: "app-store",
skip_package_ipa: true,
xcargs: build_xcargs(group_id: DEV_GROUP_ID),
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
export_options: {
provisioningProfiles: {
DEV_BUNDLE_ID => main_profile_name,
+1
View File
@@ -30,6 +30,7 @@ const int kTimelineAssetLoadBatchSize = 1024;
const int kTimelineAssetLoadOppositeSize = 64;
// Widget keys
const String appShareGroupId = "group.app.immich.share";
const String kWidgetAuthToken = "widget_auth_token";
const String kWidgetServerEndpoint = "widget_server_url";
const String kWidgetCustomHeaders = "widget_custom_headers";
@@ -11,7 +11,6 @@ class RemoteAsset extends BaseAsset {
final String ownerId;
final String? stackId;
final DateTime? uploadedAt;
final DateTime? deletedAt;
const RemoteAsset({
required this.id,
@@ -32,7 +31,6 @@ class RemoteAsset extends BaseAsset {
super.livePhotoVideoId,
this.stackId,
required super.isEdited,
this.deletedAt,
}) : localAssetId = localId;
@override
@@ -50,8 +48,6 @@ class RemoteAsset extends BaseAsset {
@override
bool get isEditable => isImage && !isMotionPhoto && !isAnimatedImage;
bool get isTrashed => deletedAt != null;
@override
String toString() {
return '''Asset {
@@ -90,8 +86,7 @@ class RemoteAsset extends BaseAsset {
thumbHash == other.thumbHash &&
visibility == other.visibility &&
stackId == other.stackId &&
uploadedAt == other.uploadedAt &&
deletedAt == other.deletedAt;
uploadedAt == other.uploadedAt;
}
@override
@@ -103,8 +98,7 @@ class RemoteAsset extends BaseAsset {
thumbHash.hashCode ^
visibility.hashCode ^
stackId.hashCode ^
uploadedAt.hashCode ^
deletedAt.hashCode;
uploadedAt.hashCode;
RemoteAsset copyWith({
String? id,
@@ -125,7 +119,6 @@ class RemoteAsset extends BaseAsset {
String? livePhotoVideoId,
String? stackId,
bool? isEdited,
DateTime? deletedAt,
}) {
return RemoteAsset(
id: id ?? this.id,
@@ -146,7 +139,6 @@ class RemoteAsset extends BaseAsset {
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
stackId: stackId ?? this.stackId,
isEdited: isEdited ?? this.isEdited,
deletedAt: deletedAt ?? this.deletedAt,
);
}
}
@@ -164,7 +156,6 @@ class RemoteAssetExif extends RemoteAsset {
required super.createdAt,
required super.updatedAt,
super.uploadedAt,
super.deletedAt,
super.width,
super.height,
super.durationMs,
@@ -202,7 +193,6 @@ class RemoteAssetExif extends RemoteAsset {
DateTime? createdAt,
DateTime? updatedAt,
DateTime? uploadedAt,
DateTime? deletedAt,
int? width,
int? height,
int? durationMs,
@@ -224,7 +214,6 @@ class RemoteAssetExif extends RemoteAsset {
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
uploadedAt: uploadedAt ?? this.uploadedAt,
deletedAt: deletedAt ?? this.deletedAt,
width: width ?? this.width,
height: height ?? this.height,
durationMs: durationMs ?? this.durationMs,
@@ -1,41 +1,26 @@
import 'package:immich_mobile/domain/models/config/cleanup_config.dart';
import 'package:immich_mobile/domain/models/config/image_config.dart';
import 'package:immich_mobile/domain/models/config/map_config.dart';
import 'package:immich_mobile/domain/models/config/theme_config.dart';
import 'package:immich_mobile/domain/models/config/timeline_config.dart';
import 'package:immich_mobile/domain/models/config/viewer_config.dart';
class AppConfig {
final ThemeConfig theme;
final CleanupConfig cleanup;
final MapConfig map;
final TimelineConfig timeline;
final ImageConfig image;
final ViewerConfig viewer;
const AppConfig({
this.theme = const .new(),
this.cleanup = const .new(),
this.map = const .new(),
this.timeline = const .new(),
this.image = const .new(),
this.viewer = const .new(),
});
AppConfig copyWith({
ThemeConfig? theme,
CleanupConfig? cleanup,
MapConfig? map,
TimelineConfig? timeline,
ImageConfig? image,
ViewerConfig? viewer,
}) => .new(
AppConfig copyWith({ThemeConfig? theme, CleanupConfig? cleanup, MapConfig? map, TimelineConfig? timeline}) => .new(
theme: theme ?? this.theme,
cleanup: cleanup ?? this.cleanup,
map: map ?? this.map,
timeline: timeline ?? this.timeline,
image: image ?? this.image,
viewer: viewer ?? this.viewer,
);
@override
@@ -45,14 +30,11 @@ class AppConfig {
other.theme == theme &&
other.cleanup == cleanup &&
other.map == map &&
other.timeline == timeline &&
other.image == image &&
other.viewer == viewer);
other.timeline == timeline);
@override
int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer);
int get hashCode => Object.hash(theme, cleanup, map, timeline);
@override
String toString() =>
'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer)';
String toString() => 'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline)';
}
@@ -1,20 +0,0 @@
class ImageConfig {
final bool preferRemote;
final bool loadOriginal;
const ImageConfig({this.preferRemote = false, this.loadOriginal = false});
ImageConfig copyWith({bool? preferRemote, bool? loadOriginal}) =>
ImageConfig(preferRemote: preferRemote ?? this.preferRemote, loadOriginal: loadOriginal ?? this.loadOriginal);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is ImageConfig && other.preferRemote == preferRemote && other.loadOriginal == loadOriginal);
@override
int get hashCode => Object.hash(preferRemote, loadOriginal);
@override
String toString() => 'ImageConfig(preferRemoteImage: $preferRemote, loadOriginal: $loadOriginal)';
}
@@ -1,37 +0,0 @@
class ViewerConfig {
final bool loopVideo;
final bool loadOriginalVideo;
final bool autoPlayVideo;
final bool tapToNavigate;
const ViewerConfig({
this.loopVideo = true,
this.loadOriginalVideo = false,
this.autoPlayVideo = true,
this.tapToNavigate = false,
});
ViewerConfig copyWith({bool? loopVideo, bool? loadOriginalVideo, bool? autoPlayVideo, bool? tapToNavigate}) =>
ViewerConfig(
loopVideo: loopVideo ?? this.loopVideo,
loadOriginalVideo: loadOriginalVideo ?? this.loadOriginalVideo,
autoPlayVideo: autoPlayVideo ?? this.autoPlayVideo,
tapToNavigate: tapToNavigate ?? this.tapToNavigate,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is ViewerConfig &&
other.loopVideo == loopVideo &&
other.loadOriginalVideo == loadOriginalVideo &&
other.autoPlayVideo == autoPlayVideo &&
other.tapToNavigate == tapToNavigate);
@override
int get hashCode => Object.hash(loopVideo, loadOriginalVideo, autoPlayVideo, tapToNavigate);
@override
String toString() =>
'ViewerConfig(loopVideo: $loopVideo, loadOriginalVideo: $loadOriginalVideo, autoPlayVideo: $autoPlayVideo, tapToNavigate: $tapToNavigate)';
}
@@ -24,16 +24,6 @@ enum MetadataKey<T extends Object> {
themeDynamic<bool>(.appConfig, 'theme.dynamic', false),
themeColorfulInterface<bool>(.appConfig, 'theme.colorfulInterface', true),
// Image
imagePreferRemote<bool>(.appConfig, 'image.preferRemote', false),
imageLoadOriginal<bool>(.appConfig, 'image.loadOriginal', false),
// Viewer
viewerLoopVideo<bool>(.appConfig, 'viewer.loopVideo', true),
viewerLoadOriginalVideo<bool>(.appConfig, 'viewer.loadOriginalVideo', false),
viewerAutoPlayVideo<bool>(.appConfig, 'viewer.autoPlayVideo', true),
viewerTapToNavigate<bool>(.appConfig, 'viewer.tapToNavigate', false),
// Timeline
timelineTilesPerRow<int>(.appConfig, 'timeline.tilesPerRow', 4),
timelineGroupAssetsBy<GroupAssetsBy>(
@@ -1,6 +1,10 @@
import 'package:immich_mobile/domain/models/store.model.dart';
enum Setting<T> {
loadOriginal<bool>(StoreKey.loadOriginal, false),
loadOriginalVideo<bool>(StoreKey.loadOriginalVideo, false),
autoPlayVideo<bool>(StoreKey.autoPlayVideo, true),
preferRemoteImage<bool>(StoreKey.preferRemoteImage, false),
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, false),
enableBackup<bool>(StoreKey.enableBackup, false);
+21 -6
View File
@@ -4,15 +4,29 @@ import 'package:immich_mobile/domain/models/user.model.dart';
/// Defines the data type for each value
enum StoreKey<T> {
version<int>._(0),
assetETag<String>._(1),
currentUser<UserDto>._(2),
deviceIdHash<int>._(3),
deviceId<String>._(4),
backupFailedSince<DateTime>._(5),
backupRequireWifi<bool>._(6),
backupRequireCharging<bool>._(7),
backupTriggerDelay<int>._(8),
serverUrl<String>._(10),
accessToken<String>._(11),
serverEndpoint<String>._(12),
autoBackup<bool>._(13),
backgroundBackup<bool>._(14),
sslClientCertData<String>._(15),
sslClientPasswd<String>._(16),
uploadErrorNotificationGracePeriod<int>._(106),
thumbnailCacheSize<int>._(110),
imageCacheSize<int>._(111),
albumThumbnailCacheSize<int>._(112),
selectedAlbumSortOrder<int>._(113),
advancedTroubleshooting<bool>._(114),
preferRemoteImage<bool>._(116),
selfSignedCert<bool>._(120),
selectedAlbumSortReverse<bool>._(123),
enableHapticFeedback<bool>._(126),
customHeaders<String>._(127),
@@ -28,6 +42,13 @@ enum StoreKey<T> {
// Read-only Mode settings
readonlyModeEnabled<bool>._(138),
albumGridView<bool>._(140),
loadOriginal<bool>._(101),
// Image viewer navigation settings
loopVideo<bool>._(117),
loadOriginalVideo<bool>._(136),
autoPlayVideo<bool>._(139),
tapToNavigate<bool>._(141),
// Experimental stuff
enableBackup<bool>._(1003),
@@ -36,12 +57,6 @@ enum StoreKey<T> {
syncMigrationStatus<String>._(1013),
// Legacy keys that have been migrated to the new metadata store
legacyLoopVideo<bool>._(117),
legacyLoadOriginalVideo<bool>._(136),
legacyAutoPlayVideo<bool>._(139),
legacyTapToNavigate<bool>._(141),
legacyPreferRemoteImage<bool>._(116),
legacyLoadOriginal<bool>._(101),
legacyPrimaryColor<String>._(128),
legacyDynamicTheme<bool>._(129),
legacyColorfulInterface<bool>._(130),
@@ -74,6 +74,5 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
localId: localId,
stackId: stackId,
isEdited: isEdited,
deletedAt: deletedAt,
);
}
@@ -132,13 +132,6 @@ extension<T extends Object> on MetadataDomain<T> {
groupAssetsBy: repo._read(.timelineGroupAssetsBy),
storageIndicator: repo._read(.timelineStorageIndicator),
),
image: .new(preferRemote: repo._read(.imagePreferRemote), loadOriginal: repo._read(.imageLoadOriginal)),
viewer: .new(
loopVideo: repo._read(.viewerLoopVideo),
loadOriginalVideo: repo._read(.viewerLoadOriginalVideo),
autoPlayVideo: repo._read(.viewerAutoPlayVideo),
tapToNavigate: repo._read(.viewerTapToNavigate),
),
);
case .systemConfig:
repo._systemConfig = .new(logLevel: repo._read(.logLevel));
+142 -5
View File
@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
@@ -15,11 +16,16 @@ import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.w
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
import 'package:immich_mobile/providers/permission.provider.dart';
import 'package:immich_mobile/providers/sync_status.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@RoutePage()
@@ -162,11 +168,7 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
),
),
},
TextButton.icon(
icon: const Icon(Icons.info_outline_rounded),
onPressed: () => context.pushRoute(const DriftUploadDetailRoute()),
label: Text("view_details".t(context: context)),
),
const _BackupFooter(),
],
],
),
@@ -177,6 +179,137 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
}
}
class _BackupFooter extends ConsumerStatefulWidget {
const _BackupFooter();
@override
ConsumerState<_BackupFooter> createState() => _BackupFooterState();
}
class _BackupFooterState extends ConsumerState<_BackupFooter> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (CurrentPlatform.isAndroid && state == AppLifecycleState.resumed && mounted) {
unawaited(ref.read(notificationPermissionProvider.notifier).getNotificationPermission());
unawaited(ref.read(batteryOptimizationProvider.notifier).getBatteryOptimizationPermission());
}
}
void showPermissionsDialog() {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
content: Text(context.t.notification_permission_dialog_content),
actions: [
ImmichTextButton(
labelText: context.t.cancel,
variant: .ghost,
expanded: false,
onPressed: () => ContextHelper(ctx).pop(),
),
ImmichTextButton(
labelText: context.t.settings,
variant: .ghost,
expanded: false,
onPressed: () {
ContextHelper(context).pop();
openAppSettings();
},
),
],
),
);
}
void showBatteryOptimizationInfo() {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext ctx) {
return AlertDialog(
title: Text(context.t.backup_controller_page_background_battery_info_title),
content: SingleChildScrollView(child: Text(context.t.backup_controller_page_background_battery_info_message)),
actions: [
ImmichTextButton(
labelText: context.t.backup_controller_page_background_battery_info_link,
variant: .ghost,
expanded: false,
onPressed: () => launchUrl(Uri.parse('https://dontkillmyapp.com'), mode: LaunchMode.externalApplication),
),
ImmichTextButton(
labelText: context.t.backup_controller_page_background_battery_info_ok,
variant: .ghost,
expanded: false,
onPressed: () => ContextHelper(ctx).pop(),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
final isBackupEnabled = ref.watch(_backupStatusProvider).valueOrNull ?? false;
final notificationStatus = ref.watch(notificationPermissionProvider);
final batteryOptimizationStatus = ref.watch(batteryOptimizationProvider).valueOrNull;
return Column(
children: [
if (CurrentPlatform.isAndroid && isBackupEnabled) ...[
if (notificationStatus != PermissionStatus.granted)
TextButton.icon(
iconAlignment: .end,
icon: Icon(Icons.open_in_new_outlined, color: context.colorScheme.onSurfaceSecondary),
label: Text(
context.t.notification_backup_reliability,
textAlign: TextAlign.left,
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
onPressed: () {
ref.read(notificationPermissionProvider.notifier).requestNotificationPermission().then((p) {
if (p == PermissionStatus.permanentlyDenied) {
showPermissionsDialog();
}
});
},
),
if (notificationStatus != PermissionStatus.granted && batteryOptimizationStatus != PermissionStatus.granted)
const Divider(indent: 32, endIndent: 32),
if (batteryOptimizationStatus != PermissionStatus.granted)
TextButton.icon(
iconAlignment: .end,
icon: Icon(Icons.open_in_new_outlined, color: context.colorScheme.onSurfaceSecondary),
label: Text(
context.t.battery_optimization_backup_reliability,
textAlign: TextAlign.left,
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
onPressed: showBatteryOptimizationInfo,
),
],
TextButton.icon(
icon: const Icon(Icons.info_outline_rounded),
onPressed: () => context.pushRoute(const DriftUploadDetailRoute()),
label: Text(context.t.view_details),
),
],
);
}
}
class _BackupAlbumSelectionCard extends ConsumerWidget {
const _BackupAlbumSelectionCard();
@@ -527,3 +660,7 @@ class _PreparingStatusState extends ConsumerState {
);
}
}
final _backupStatusProvider = StreamProvider.autoDispose<bool?>((ref) async* {
yield* ref.watch(storeServiceProvider).watch(StoreKey.enableBackup);
});
+89
View File
@@ -0,0 +1,89 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: unused_import, unused_shown_name
// ignore_for_file: type=lint
import 'dart:async';
import 'dart:typed_data' show Float64List, Int32List, Int64List;
import 'package:flutter/services.dart';
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
Object? _extractReplyValueOrThrow(List<Object?>? replyList, String channelName, {required bool isNullValid}) {
if (replyList == null) {
throw PlatformException(
code: 'channel-error',
message: 'Unable to establish connection on channel: "$channelName".',
);
} else if (replyList.length > 1) {
throw PlatformException(code: replyList[0]! as String, message: replyList[1] as String?, details: replyList[2]);
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
}
return replyList.firstOrNull;
}
enum PermissionStatus { granted, denied, permanentlyDenied }
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
void writeValue(WriteBuffer buffer, Object? value) {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else if (value is PermissionStatus) {
buffer.putUint8(129);
writeValue(buffer, value.index);
} else {
super.writeValue(buffer, value);
}
}
@override
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
case 129:
final value = readValue(buffer) as int?;
return value == null ? null : PermissionStatus.values[value];
default:
return super.readValueOfType(type, buffer);
}
}
}
class PermissionApi {
/// Constructor for [PermissionApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
PermissionApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
Future<PermissionStatus> isIgnoringBatteryOptimizations() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.PermissionApi.isIgnoringBatteryOptimizations$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as PermissionStatus;
}
}
@@ -35,11 +35,10 @@ class BaseActionButton extends ConsumerWidget {
final miniWidth = minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0);
final iconTheme = IconTheme.of(context);
final iconSize = iconTheme.size ?? 24.0;
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
final textColor = context.themeData.textTheme.labelLarge?.color;
if (iconOnly) {
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
return IconButton(
onPressed: onPressed,
icon: Icon(iconData, size: iconSize, color: iconColor),
@@ -47,18 +46,17 @@ class BaseActionButton extends ConsumerWidget {
}
if (menuItem) {
final iconColor = this.iconColor;
final theme = context.themeData;
final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant;
return MenuItemButton(
style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)),
leadingIcon: Icon(iconData, color: iconColor),
leadingIcon: Icon(iconData, color: effectiveIconColor),
onPressed: onPressed,
child: Text(label, style: TextStyle(fontSize: 16, color: iconColor)),
child: Text(label, style: theme.textTheme.labelLarge?.copyWith(fontSize: 16, color: iconColor)),
);
}
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
return ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: MaterialButton(
@@ -18,15 +18,8 @@ class DeletePermanentActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
final bool useShortLabel;
const DeletePermanentActionButton({
super.key,
required this.source,
this.iconOnly = false,
this.menuItem = false,
this.useShortLabel = false,
});
const DeletePermanentActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
@@ -71,7 +64,7 @@ class DeletePermanentActionButton extends ConsumerWidget {
return BaseActionButton(
maxWidth: 110.0,
iconData: Icons.delete_forever,
label: useShortLabel ? "delete".t(context: context) : "delete_permanently".t(context: context),
label: "delete_permanently".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
@@ -1,55 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class RestoreActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const RestoreActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).restoreTrash(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final successMessage = 'assets_restored_count'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.history_rounded,
label: 'restore'.t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
maxWidth: 100.0,
);
}
}
@@ -17,10 +17,11 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widg
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
@@ -230,7 +231,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
return;
}
final tapToNavigate = ref.read(metadataProvider).appConfig.viewer.tapToNavigate;
final tapToNavigate = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.tapToNavigate);
if (!tapToNavigate) {
_viewer.toggleControls();
return;
@@ -2,19 +2,15 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@@ -37,31 +33,23 @@ class ViewerBottomBar extends ConsumerWidget {
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
final isInLockedView = ref.watch(inLockedViewProvider);
final serverInfo = ref.watch(serverInfoProvider);
final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash;
final originalTheme = context.themeData;
final actions = <Widget>[
if (isInTrash && isOwner && asset.hasRemote)
const RestoreActionButton(source: ActionSource.viewer)
else
const ShareActionButton(source: ActionSource.viewer),
const ShareActionButton(source: ActionSource.viewer),
if (!isInLockedView) ...[
if (!isInTrash) ...[
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
// edit sync was added in 2.6.0
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
const EditImageActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
],
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
// edit sync was added in 2.6.0
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
const EditImageActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
if (isOwner) ...[
if (asset.isLocalOnly)
const DeleteLocalActionButton(source: ActionSource.viewer)
else if (asset.isTrashed)
const DeletePermanentActionButton(source: ActionSource.viewer, useShortLabel: true)
else
const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
asset.isLocalOnly
? const DeleteLocalActionButton(source: ActionSource.viewer)
: const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
],
],
];
@@ -1,78 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
class MotionPhotoPlayButton extends ConsumerWidget {
const MotionPhotoPlayButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(assetViewerProvider.select((state) => state.currentAsset));
final isPlaying = ref.watch(isPlayingMotionVideoProvider);
final showControls = ref.watch(assetViewerProvider.select((state) => state.showingControls));
final isShowingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
if (asset == null || !asset.isMotionPhoto || isShowingDetails) {
return const SizedBox.shrink();
}
return IgnorePointer(
ignoring: !showControls,
child: AnimatedOpacity(
opacity: showControls ? 1.0 : 0.0,
duration: Durations.short2,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Center(
child: _MotionButton(
isPlaying: isPlaying,
onPressed: ref.read(isPlayingMotionVideoProvider.notifier).toggle,
),
),
),
),
),
);
}
}
class _MotionButton extends StatelessWidget {
final bool isPlaying;
final VoidCallback onPressed;
const _MotionButton({required this.isPlaying, required this.onPressed});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.grey[900]!.withValues(alpha: 0.4),
borderRadius: const BorderRadius.all(Radius.circular(24)),
child: InkWell(
onTap: onPressed,
borderRadius: const BorderRadius.all(Radius.circular(24)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isPlaying ? Icons.motion_photos_pause_outlined : Icons.play_circle_outline_rounded,
color: Colors.white,
size: 16,
),
const SizedBox(width: 8),
Text(
CurrentPlatform.isAndroid ? 'motion'.t(context: context) : 'live'.t(context: context),
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500),
),
],
),
),
),
);
}
}
@@ -3,17 +3,21 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart';
@@ -128,7 +132,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
final remoteId = (videoAsset as RemoteAsset).id;
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final isOriginalVideo = ref.read(metadataProvider).appConfig.viewer.loadOriginalVideo;
final isOriginalVideo = ref.read(settingsProvider).get<bool>(Setting.loadOriginalVideo);
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
final String videoUrl = videoAsset.livePhotoVideoId != null
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
@@ -161,7 +165,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
return;
}
final autoPlayVideo = ref.read(metadataProvider).appConfig.viewer.autoPlayVideo;
final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo);
if (autoPlayVideo || widget.asset.isMotionPhoto) {
await _notifier.play();
}
@@ -212,7 +216,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
}
await _notifier.load(source);
final loopVideo = ref.read(metadataProvider).appConfig.viewer.loopVideo;
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo);
await _notifier.setVolume(1);
}
@@ -1,5 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
@@ -11,13 +10,11 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_act
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/timezone.dart';
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
const ViewerTopAppBar({super.key});
@@ -98,17 +95,16 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
),
SafeArea(
bottom: false,
child: SizedBox(
height: preferredSize.height,
child: SizedBox.square(
child: Theme(
data: context.themeData.copyWith(iconTheme: const IconThemeData(size: 22, color: Colors.white)),
child: NavigationToolbar(
centerMiddle: true,
leading: const _AppBarBackButton(),
middle: showingDetails ? null : _AssetInfoTitle(asset: asset),
trailing: !showingDetails && !isReadonlyModeEnabled
? Row(mainAxisSize: MainAxisSize.min, children: isInLockedView ? lockedViewActions : actions)
: null,
child: Row(
children: [
const _AppBarBackButton(),
const Spacer(),
if (!showingDetails && !isReadonlyModeEnabled)
if (isInLockedView) ...lockedViewActions else ...actions,
],
),
),
),
@@ -143,32 +139,3 @@ class _AppBarBackButton extends ConsumerWidget {
);
}
}
class _AssetInfoTitle extends ConsumerWidget {
final BaseAsset asset;
const _AssetInfoTitle({required this.asset});
@override
Widget build(BuildContext context, WidgetRef ref) {
DateTime dateTime = asset.createdAt.toLocal();
final currentYear = DateTime.now().year;
final exifInfo = ref.watch(assetExifProvider(asset)).valueOrNull;
if (exifInfo?.dateTimeOriginal != null) {
(dateTime, _) = applyTimezoneOffset(dateTime: exifInfo!.dateTimeOriginal!, timeZone: exifInfo.timeZone);
}
final isCurrentYear = dateTime.year == currentYear;
final dateFormatted = isCurrentYear ? DateFormat.MMMd().format(dateTime) : DateFormat.yMMMd().format(dateTime);
final timeFormatted = DateFormat.jm().format(dateTime);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(dateFormatted, style: context.textTheme.labelLarge?.copyWith(color: Colors.white)),
Text(timeFormatted, style: context.textTheme.labelMedium?.copyWith(color: Colors.white70)),
],
);
}
}
@@ -3,8 +3,9 @@ import 'dart:ui' as ui;
import 'package:async/async.dart';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
@@ -188,6 +189,4 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai
}
bool _shouldUseLocalAsset(BaseAsset asset) =>
asset.hasLocal &&
(!asset.hasRemote || !MetadataRepository.instance.appConfig.image.preferRemote) &&
!asset.isEdited;
asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage)) && !asset.isEdited;
@@ -1,8 +1,9 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/presentation/widgets/images/animated_image_stream_completer.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
@@ -104,7 +105,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
return;
}
final loadOriginal = MetadataRepository.instance.appConfig.image.loadOriginal;
final loadOriginal = Store.get(StoreKey.loadOriginal, false);
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
var request = this.request = LocalImageRequest(
localId: key.id,
@@ -1,8 +1,9 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/presentation/widgets/images/animated_image_stream_completer.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
@@ -122,7 +123,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
edited: key.edited,
),
);
final loadOriginal = assetType == AssetType.image && MetadataRepository.instance.appConfig.image.loadOriginal;
final loadOriginal = assetType == AssetType.image && AppSetting.get(Setting.loadOriginal);
yield* loadRequest(previewRequest, decode, isFinal: !loadOriginal);
if (!loadOriginal) {
@@ -11,7 +11,7 @@ import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/notification_permission.provider.dart';
import 'package:immich_mobile/providers/permission.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
+3 -1
View File
@@ -11,11 +11,12 @@ import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/auth.service.dart';
import 'package:immich_mobile/services/background_upload.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/secure_storage.service.dart';
import 'package:immich_mobile/services/background_upload.service.dart';
import 'package:immich_mobile/services/widget.service.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
@@ -143,6 +144,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
// Due to the flow of the code, this will always happen on first login
user = serverUser;
await Store.put(StoreKey.deviceId, deviceId);
await Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
}
} on ApiException catch (error, stackTrace) {
if (error.code == 401) {
@@ -3,9 +3,10 @@ import 'package:immich_mobile/domain/services/background_worker.service.dart';
import 'package:immich_mobile/platform/background_worker_api.g.dart';
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
import 'package:immich_mobile/platform/connectivity_api.g.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/platform/local_image_api.g.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/platform/network_api.g.dart';
import 'package:immich_mobile/platform/permission_api.g.dart';
import 'package:immich_mobile/platform/remote_image_api.g.dart';
final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
@@ -18,6 +19,8 @@ final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
final connectivityApiProvider = Provider<ConnectivityApi>((_) => ConnectivityApi());
final permissionApiProvider = Provider<PermissionApi>((_) => PermissionApi());
final localImageApi = LocalImageApi();
final remoteImageApi = RemoteImageApi();
@@ -1,6 +1,9 @@
import 'dart:async';
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/platform/permission_api.g.dart' as pm;
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:permission_handler/permission_handler.dart';
class NotificationPermissionNotifier extends StateNotifier<PermissionStatus> {
@@ -39,3 +42,32 @@ class NotificationPermissionNotifier extends StateNotifier<PermissionStatus> {
final notificationPermissionProvider = StateNotifierProvider<NotificationPermissionNotifier, PermissionStatus>(
(ref) => NotificationPermissionNotifier(),
);
final batteryOptimizationProvider = AsyncNotifierProvider<BatteryOptimizationNotifier, PermissionStatus>(
BatteryOptimizationNotifier.new,
);
class BatteryOptimizationNotifier extends AsyncNotifier<PermissionStatus> {
Future<PermissionStatus> getBatteryOptimizationPermission() async {
final PermissionStatus status;
final isIgnoring = await ref.read(permissionApiProvider).isIgnoringBatteryOptimizations().then((p) => p.toStatus());
if (isIgnoring == PermissionStatus.granted) {
status = PermissionStatus.granted;
} else {
status = PermissionStatus.denied;
}
state = AsyncValue.data(status);
return status;
}
@override
FutureOr<PermissionStatus> build() => getBatteryOptimizationPermission();
}
extension on pm.PermissionStatus {
PermissionStatus toStatus() => switch (this) {
pm.PermissionStatus.granted => PermissionStatus.granted,
pm.PermissionStatus.denied => PermissionStatus.denied,
pm.PermissionStatus.permanentlyDenied => PermissionStatus.permanentlyDenied,
};
}
@@ -13,4 +13,8 @@ class WidgetRepository {
Future<void> refresh(String iosName, String androidName) async {
await HomeWidget.updateWidget(iOSName: iosName, qualifiedAndroidName: androidName);
}
Future<void> setAppGroupId(String appGroupId) async {
await HomeWidget.setAppGroupId(appGroupId);
}
}
+17 -42
View File
@@ -15,62 +15,37 @@ class AuthGuard extends AutoRouteGuard {
final ApiService _apiService;
final AuthService _authService;
final _log = Logger("AuthGuard");
bool _validateInFlight = false;
AuthGuard(this._apiService, this._authService);
@override
void onNavigation(NavigationResolver resolver, StackRouter router) {
// Synchronously check for the access token. auto_route awaits async
// guards, so we keep this function fully sync and validate the token in
// the background — otherwise a slow validateAccessToken() request would
// block the route transition for as long as the OS-level HTTP timeout.
try {
Store.get(StoreKey.accessToken);
} on StoreKeyNotFoundException catch (_) {
_log.warning('No access token in the store.');
resolver.next(false);
unawaited(router.replaceAll([const LoginRoute()]));
return;
}
void onNavigation(NavigationResolver resolver, StackRouter router) async {
resolver.next(true);
unawaited(_validateAccessTokenInBackground(router));
}
Future<void> _validateAccessTokenInBackground(StackRouter router) async {
if (_validateInFlight) {
return;
}
final token = Store.tryGet(StoreKey.accessToken);
if (token == null) {
return;
}
_validateInFlight = true;
try {
// Look in the store for an access token
Store.get(StoreKey.accessToken);
// Validate the access token with the server
final res = await _apiService.authenticationApi.validateAccessToken();
if (res == null || res.authStatus != true) {
// Token may have changed during validation (user logged out + logged in
// again); only act if it still applies to the current session.
if (Store.tryGet(StoreKey.accessToken) != token) {
return;
}
// If the access token is invalid, take user back to login
_log.fine('User token is invalid. Redirecting to login');
await router.replaceAll([const LoginRoute()]);
await _authService.clearLocalData();
unawaited(router.replaceAll([const LoginRoute()]).then((_) => _authService.clearLocalData()));
}
} on StoreKeyNotFoundException catch (_) {
// If there is no access token, take us to the login page
_log.warning('No access token in the store.');
unawaited(router.replaceAll([const LoginRoute()]));
return;
} on ApiException catch (e) {
if (e.code != HttpStatus.unauthorized) {
// On an unauthorized request, take us to the login page
if (e.code == HttpStatus.unauthorized) {
_log.warning("Unauthorized access token.");
unawaited(router.replaceAll([const LoginRoute()]).then((_) => _authService.clearLocalData()));
return;
}
if (Store.tryGet(StoreKey.accessToken) != token) {
return;
}
_log.warning("Unauthorized access token.");
await router.replaceAll([const LoginRoute()]);
await _authService.clearLocalData();
} catch (e) {
// Otherwise, this is not fatal, but we still log the warning
_log.warning('Error validating access token from server: $e');
} finally {
_validateInFlight = false;
}
}
}
@@ -2,9 +2,24 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
enum AppSettingsEnum<T> {
loadOriginal<bool>(StoreKey.loadOriginal, "loadOriginal", false),
uploadErrorNotificationGracePeriod<int>(
StoreKey.uploadErrorNotificationGracePeriod,
"uploadErrorNotificationGracePeriod",
2,
),
thumbnailCacheSize<int>(StoreKey.thumbnailCacheSize, "thumbnailCacheSize", 10000),
imageCacheSize<int>(StoreKey.imageCacheSize, "imageCacheSize", 350),
albumThumbnailCacheSize<int>(StoreKey.albumThumbnailCacheSize, "albumThumbnailCacheSize", 200),
selectedAlbumSortOrder<int>(StoreKey.selectedAlbumSortOrder, "selectedAlbumSortOrder", 2),
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true),
loadOriginalVideo<bool>(StoreKey.loadOriginalVideo, "loadOriginalVideo", false),
autoPlayVideo<bool>(StoreKey.autoPlayVideo, "autoPlayVideo", true),
tapToNavigate<bool>(StoreKey.tapToNavigate, "tapToNavigate", false),
allowSelfSignedSSLCert<bool>(StoreKey.selfSignedCert, null, false),
selectedAlbumSortReverse<bool>(StoreKey.selectedAlbumSortReverse, null, true),
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
syncAlbums<bool>(StoreKey.syncAlbums, null, false),
+1
View File
@@ -123,6 +123,7 @@ class AuthService {
_authRepository.clearLocalData(),
Store.delete(StoreKey.currentUser),
Store.delete(StoreKey.accessToken),
Store.delete(StoreKey.assetETag),
Store.delete(StoreKey.autoEndpointSwitching),
Store.delete(StoreKey.preferredWifiName),
Store.delete(StoreKey.localEndpoint),
@@ -394,13 +394,9 @@ class BackgroundUploadService {
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final url = Uri.parse('$serverEndpoint/assets').toString();
final headers = ApiService.getRequestHeaders();
final deviceId = Store.get(StoreKey.deviceId);
final (baseDirectory, directory, filename) = await Task.split(filePath: file.path);
final fieldsMap = {
'filename': originalFileName ?? filename,
// deviceAssetId/deviceId required by server v2.7.5 and below (drop in v4.0 per #27818).
'deviceAssetId': deviceAssetId ?? '',
'deviceId': deviceId,
'fileCreatedAt': createdAt.toUtc().toIso8601String(),
'fileModifiedAt': modifiedAt.toUtc().toIso8601String(),
'isFavorite': isFavorite?.toString() ?? 'false',
@@ -5,8 +5,6 @@ import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
@@ -321,12 +319,8 @@ class ForegroundUploadService {
}
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
final deviceId = Store.get(StoreKey.deviceId);
final fields = {
// deviceAssetId/deviceId required by server v2.7.5 and below (drop in v4.0 per #27818).
'deviceAssetId': asset.localId!,
'deviceId': deviceId,
'fileCreatedAt': asset.createdAt.toUtc().toIso8601String(),
'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(),
'isFavorite': asset.isFavorite.toString(),
@@ -432,9 +426,6 @@ class ForegroundUploadService {
final filename = p.basename(file.path);
final fields = {
// deviceAssetId/deviceId required by server v2.7.5 and below (drop in v4.0 per #27818).
'deviceAssetId': deviceAssetId,
'deviceId': Store.get(StoreKey.deviceId),
'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(),
'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(),
'isFavorite': 'false',
+2
View File
@@ -12,6 +12,7 @@ class WidgetService {
const WidgetService(this._repository);
Future<void> writeCredentials(String serverURL, String sessionKey, String? customHeaders) async {
await _repository.setAppGroupId(appShareGroupId);
await _repository.saveData(kWidgetServerEndpoint, serverURL);
await _repository.saveData(kWidgetAuthToken, sessionKey);
@@ -24,6 +25,7 @@ class WidgetService {
}
Future<void> clearCredentials() async {
await _repository.setAppGroupId(appShareGroupId);
await _repository.saveData(kWidgetServerEndpoint, "");
await _repository.saveData(kWidgetAuthToken, "");
await _repository.saveData(kWidgetCustomHeaders, "");
+4 -19
View File
@@ -21,7 +21,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_f
import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
@@ -82,7 +81,6 @@ enum ActionButtonType {
moveToLockFolder,
removeFromLockFolder,
removeFromAlbum,
restoreTrash,
trash,
deleteLocal,
deletePermanent,
@@ -114,17 +112,12 @@ enum ActionButtonType {
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote && //
context.isTrashEnabled && //
context.timelineOrigin != TimelineOrigin.trash,
ActionButtonType.restoreTrash =>
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote && //
context.timelineOrigin == TimelineOrigin.trash,
context.isTrashEnabled,
ActionButtonType.deletePermanent =>
context.isOwner && //
context.asset.hasRemote && //
(!context.isTrashEnabled || context.timelineOrigin == TimelineOrigin.trash || context.isInLockedView),
context.asset.hasRemote && //
!context.isTrashEnabled ||
context.isInLockedView,
ActionButtonType.delete =>
context.isOwner && //
!context.isInLockedView && //
@@ -208,11 +201,6 @@ enum ActionButtonType {
),
ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.restoreTrash => RestoreActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
),
ActionButtonType.deletePermanent => DeletePermanentActionButton(
source: context.source,
iconOnly: iconOnly,
@@ -304,7 +292,6 @@ enum ActionButtonType {
ActionButtonType.moveToLockFolder => 10,
ActionButtonType.deleteLocal => 10,
ActionButtonType.delete => 10,
ActionButtonType.restoreTrash => 10,
// 90: advancedInfo
ActionButtonType.advancedInfo => 90,
// 1: others
@@ -322,8 +309,6 @@ class ActionButtonBuilder {
ActionButtonType.delete,
ActionButtonType.archive,
ActionButtonType.unarchive,
ActionButtonType.restoreTrash,
ActionButtonType.deletePermanent,
};
static List<Widget> build(ActionButtonContext context) {
-8
View File
@@ -88,14 +88,6 @@ Future<void> _migrateTo26(Drift drift) async {
GroupAssetsBy.values,
);
await migrator.migrateBool(StoreKey.legacyStorageIndicator, MetadataKey.timelineStorageIndicator);
// Image
await migrator.migrateBool(StoreKey.legacyPreferRemoteImage, MetadataKey.imagePreferRemote);
await migrator.migrateBool(StoreKey.legacyLoadOriginal, MetadataKey.imageLoadOriginal);
// Viewer
await migrator.migrateBool(StoreKey.legacyLoopVideo, MetadataKey.viewerLoopVideo);
await migrator.migrateBool(StoreKey.legacyLoadOriginalVideo, MetadataKey.viewerLoadOriginalVideo);
await migrator.migrateBool(StoreKey.legacyAutoPlayVideo, MetadataKey.viewerAutoPlayVideo);
await migrator.migrateBool(StoreKey.legacyTapToNavigate, MetadataKey.viewerTapToNavigate);
await migrator.complete();
}
@@ -32,11 +32,7 @@ class AdvancedSettings extends HookConsumerWidget {
final isManageMediaSupported = useState(false);
final manageMediaAndroidPermission = useState(false);
final levelId = useState<int>(ref.read(systemConfigProvider).logLevel.index);
final preferRemote = useState(ref.read(appConfigProvider).image.preferRemote);
useValueChanged(
preferRemote.value,
(_, __) => ref.read(metadataProvider).write(.imagePreferRemote, preferRemote.value),
);
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
final readonlyModeEnabled = useAppSettingsState(AppSettingsEnum.readonlyModeEnabled);
final logLevel = Level.LEVELS[levelId.value].name;
@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.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/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
@@ -12,10 +12,7 @@ class ImageViewerQualitySetting extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isOriginal = useState(ref.read(appConfigProvider).image.loadOriginal);
useValueChanged<bool, void>(isOriginal.value, (_, __) {
ref.read(metadataProvider).write(.imageLoadOriginal, isOriginal.value);
});
final isOriginal = useAppSettingsState(AppSettingsEnum.loadOriginal);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -1,20 +1,18 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
class ImageViewerTapToNavigateSetting extends HookConsumerWidget {
const ImageViewerTapToNavigateSetting({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tapToNavigate = useState(ref.read(appConfigProvider).viewer.tapToNavigate);
useValueChanged<bool, void>(tapToNavigate.value, (_, __) {
ref.read(metadataProvider).write(.viewerTapToNavigate, tapToNavigate.value);
});
final tapToNavigate = useAppSettingsState(AppSettingsEnum.tapToNavigate);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -24,6 +22,7 @@ class ImageViewerTapToNavigateSetting extends HookConsumerWidget {
valueNotifier: tapToNavigate,
title: "setting_image_navigation_enable_title".tr(),
subtitle: "setting_image_navigation_enable_subtitle".tr(),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
),
],
);
@@ -1,30 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
class VideoViewerSettings extends HookConsumerWidget {
const VideoViewerSettings({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final viewer = ref.read(appConfigProvider).viewer;
final useAutoPlayVideo = useState(viewer.autoPlayVideo);
final useLoopVideo = useState(viewer.loopVideo);
final useOriginalVideo = useState(viewer.loadOriginalVideo);
useValueChanged<bool, void>(useAutoPlayVideo.value, (_, __) {
ref.read(metadataProvider).write(.viewerAutoPlayVideo, useAutoPlayVideo.value);
});
useValueChanged<bool, void>(useLoopVideo.value, (_, __) {
ref.read(metadataProvider).write(.viewerLoopVideo, useLoopVideo.value);
});
useValueChanged<bool, void>(useOriginalVideo.value, (_, __) {
ref.read(metadataProvider).write(.viewerLoadOriginalVideo, useOriginalVideo.value);
});
final useLoopVideo = useAppSettingsState(AppSettingsEnum.loopVideo);
final useOriginalVideo = useAppSettingsState(AppSettingsEnum.loadOriginalVideo);
final useAutoPlayVideo = useAppSettingsState(AppSettingsEnum.autoPlayVideo);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -37,16 +27,19 @@ class VideoViewerSettings extends HookConsumerWidget {
valueNotifier: useAutoPlayVideo,
title: "setting_video_viewer_auto_play_title".t(context: context),
subtitle: "setting_video_viewer_auto_play_subtitle".t(context: context),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
),
SettingsSwitchListTile(
valueNotifier: useLoopVideo,
title: "setting_video_viewer_looping_title".t(context: context),
subtitle: "loop_videos_description".t(context: context),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
),
SettingsSwitchListTile(
valueNotifier: useOriginalVideo,
title: "setting_video_viewer_original_video_title".t(context: context),
subtitle: "setting_video_viewer_original_video_subtitle".t(context: context),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
),
],
);
@@ -2,8 +2,11 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/notification_permission.provider.dart';
import 'package:immich_mobile/providers/permission.provider.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/widgets/settings/settings_button_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
import 'package:permission_handler/permission_handler.dart';
@@ -13,6 +16,9 @@ class NotificationSetting extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final permissionService = ref.watch(notificationPermissionProvider);
final sliderValue = useAppSettingsState(AppSettingsEnum.uploadErrorNotificationGracePeriod);
final hasPermission = permissionService == PermissionStatus.granted;
openAppNotificationSettings(BuildContext ctx) {
@@ -35,6 +41,8 @@ class NotificationSetting extends HookConsumerWidget {
);
}
final String formattedValue = _formatSliderValue(sliderValue.value.toDouble());
final notificationSettings = [
if (!hasPermission)
SettingsButtonListTile(
@@ -49,8 +57,32 @@ class NotificationSetting extends HookConsumerWidget {
}
}),
),
SettingsSliderListTile(
enabled: hasPermission,
valueNotifier: sliderValue,
text: 'setting_notifications_notify_failures_grace_period'.tr(namedArgs: {'duration': formattedValue}),
maxValue: 5.0,
noDivisons: 5,
label: formattedValue,
),
];
return SettingsSubPageScaffold(settings: notificationSettings);
}
}
String _formatSliderValue(double v) {
if (v == 0.0) {
return 'setting_notifications_notify_immediately'.tr();
} else if (v == 1.0) {
return 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '30'});
} else if (v == 2.0) {
return 'setting_notifications_notify_hours'.tr(namedArgs: {'count': '2'});
} else if (v == 3.0) {
return 'setting_notifications_notify_hours'.tr(namedArgs: {'count': '8'});
} else if (v == 4.0) {
return 'setting_notifications_notify_hours'.tr(namedArgs: {'count': '24'});
} else {
return 'setting_notifications_notify_never'.tr();
}
}
+8 -14
View File
@@ -30,14 +30,8 @@ run = "dart run build_runner watch --delete-conflicting-outputs"
alias = "pigeon"
description = "Generate pigeon platform code"
run = [
"dart run pigeon --input pigeon/native_sync_api.dart",
"dart run pigeon --input pigeon/local_image_api.dart",
"dart run pigeon --input pigeon/remote_image_api.dart",
"dart run pigeon --input pigeon/background_worker_api.dart",
"dart run pigeon --input pigeon/background_worker_lock_api.dart",
"dart run pigeon --input pigeon/connectivity_api.dart",
"dart run pigeon --input pigeon/network_api.dart",
"dart format lib/platform/native_sync_api.g.dart lib/platform/local_image_api.g.dart lib/platform/remote_image_api.g.dart lib/platform/background_worker_api.g.dart lib/platform/background_worker_lock_api.g.dart lib/platform/connectivity_api.g.dart lib/platform/network_api.g.dart",
"ls pigeon/*.dart | xargs -n1 -P4 -I{} dart run pigeon --input {}",
"dart format lib/platform/",
]
[tasks."codegen:translation"]
@@ -131,10 +125,10 @@ run = "dcm fix lib"
[tasks.checklist]
run = [
{task = "codegen:pigeon" },
{task = "codegen:dart" },
{task = "codegen:translation" },
{task = "analyze" },
{task = "format" },
{task = "test" },
{ task = "codegen:pigeon" },
{ task = "codegen:dart" },
{ task = "codegen:translation" },
{ task = "analyze" },
{ task = "format" },
{ task = "test" },
]
+17
View File
@@ -0,0 +1,17 @@
import 'package:pigeon/pigeon.dart';
enum PermissionStatus { granted, denied, permanentlyDenied }
@ConfigurePigeon(
PigeonOptions(
dartOut: 'lib/platform/permission_api.g.dart',
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt',
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.permission'),
dartOptions: DartOptions(),
dartPackageName: 'immich_mobile',
),
)
@HostApi()
abstract class PermissionApi {
PermissionStatus isIgnoringBatteryOptimizations();
}
@@ -9,8 +9,9 @@ import 'package:mocktail/mocktail.dart';
import '../../infrastructure/repository.mock.dart';
const _kAccessToken = '#ThisIsAToken';
const _kEnableBackup = false;
const _kBackgroundBackup = false;
const _kVersion = 2;
final _kBackupFailedSince = DateTime.utc(2023);
void main() {
late StoreService sut;
@@ -23,13 +24,15 @@ void main() {
// For generics, we need to provide fallback to each concrete type to avoid runtime errors
registerFallbackValue(StoreKey.accessToken);
registerFallbackValue(StoreKey.backupTriggerDelay);
registerFallbackValue(StoreKey.enableBackup);
registerFallbackValue(StoreKey.backgroundBackup);
registerFallbackValue(StoreKey.backupFailedSince);
when(() => mockDriftStoreRepo.getAll()).thenAnswer(
(_) async => [
const StoreDto(StoreKey.accessToken, _kAccessToken),
const StoreDto(StoreKey.enableBackup, _kEnableBackup),
const StoreDto(StoreKey.backgroundBackup, _kBackgroundBackup),
const StoreDto(StoreKey.version, _kVersion),
StoreDto(StoreKey.backupFailedSince, _kBackupFailedSince),
],
);
when(() => mockDriftStoreRepo.watchAll()).thenAnswer((_) => controller.stream);
@@ -46,8 +49,9 @@ void main() {
test('Populates the internal cache on init', () {
verify(() => mockDriftStoreRepo.getAll()).called(1);
expect(sut.tryGet(StoreKey.accessToken), _kAccessToken);
expect(sut.tryGet(StoreKey.enableBackup), _kEnableBackup);
expect(sut.tryGet(StoreKey.backgroundBackup), _kBackgroundBackup);
expect(sut.tryGet(StoreKey.version), _kVersion);
expect(sut.tryGet(StoreKey.backupFailedSince), _kBackupFailedSince);
// Other keys should be null
expect(sut.tryGet(StoreKey.currentUser), isNull);
});
@@ -147,8 +151,9 @@ void main() {
await sut.clear();
verify(() => mockDriftStoreRepo.deleteAll()).called(1);
expect(sut.tryGet(StoreKey.accessToken), isNull);
expect(sut.tryGet(StoreKey.enableBackup), isNull);
expect(sut.tryGet(StoreKey.backgroundBackup), isNull);
expect(sut.tryGet(StoreKey.version), isNull);
expect(sut.tryGet(StoreKey.backupFailedSince), isNull);
});
});
}
@@ -12,8 +12,9 @@ import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'
import '../../fixtures/user.stub.dart';
const _kTestAccessToken = "#TestToken";
final _kTestBackupFailed = DateTime(2025, 2, 20, 11, 45);
const _kTestVersion = 10;
const _kTestBackupRequireCharging = false;
const _kTestBackupRequireWifi = false;
final _kTestUser = UserStub.admin;
Future<void> _populateStore(Drift db) async {
@@ -21,8 +22,16 @@ Future<void> _populateStore(Drift db) async {
batch.insert(
db.storeEntity,
StoreEntityCompanion(
id: Value(StoreKey.backupRequireCharging.id),
intValue: const Value(_kTestBackupRequireCharging ? 1 : 0),
id: Value(StoreKey.backupRequireWifi.id),
intValue: const Value(_kTestBackupRequireWifi ? 1 : 0),
stringValue: const Value(null),
),
);
batch.insert(
db.storeEntity,
StoreEntityCompanion(
id: Value(StoreKey.backupFailedSince.id),
intValue: Value(_kTestBackupFailed.millisecondsSinceEpoch),
stringValue: const Value(null),
),
);
@@ -75,12 +84,20 @@ void main() {
expect(accessToken, _kTestAccessToken);
});
test('converts datetime', () async {
DateTime? backupFailedSince = await sut.tryGet(StoreKey.backupFailedSince);
expect(backupFailedSince, isNull);
await sut.upsert(StoreKey.backupFailedSince, _kTestBackupFailed);
backupFailedSince = await sut.tryGet(StoreKey.backupFailedSince);
expect(backupFailedSince, _kTestBackupFailed);
});
test('converts bool', () async {
bool? backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
expect(backupRequireCharging, isNull);
await sut.upsert(StoreKey.backupRequireCharging, _kTestBackupRequireCharging);
backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
expect(backupRequireCharging, _kTestBackupRequireCharging);
bool? backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi);
expect(backupRequireWifi, isNull);
await sut.upsert(StoreKey.backupRequireWifi, _kTestBackupRequireWifi);
backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi);
expect(backupRequireWifi, _kTestBackupRequireWifi);
});
test('converts user', () async {
@@ -98,11 +115,11 @@ void main() {
});
test('delete()', () async {
bool? backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
expect(backupRequireCharging, isFalse);
await sut.delete(StoreKey.backupRequireCharging);
backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
expect(backupRequireCharging, isNull);
bool? backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi);
expect(backupRequireWifi, isFalse);
await sut.delete(StoreKey.backupRequireWifi);
backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi);
expect(backupRequireWifi, isNull);
});
test('deleteAll()', () async {
@@ -147,12 +164,14 @@ void main() {
emitsInOrder([
[
const StoreDto<Object>(StoreKey.version, _kTestVersion),
const StoreDto<Object>(StoreKey.backupRequireCharging, _kTestBackupRequireCharging),
StoreDto<Object>(StoreKey.backupFailedSince, _kTestBackupFailed),
const StoreDto<Object>(StoreKey.backupRequireWifi, _kTestBackupRequireWifi),
const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken),
],
[
const StoreDto<Object>(StoreKey.version, _kTestVersion + 10),
const StoreDto<Object>(StoreKey.backupRequireCharging, _kTestBackupRequireCharging),
StoreDto<Object>(StoreKey.backupFailedSince, _kTestBackupFailed),
const StoreDto<Object>(StoreKey.backupRequireWifi, _kTestBackupRequireWifi),
const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken),
],
]),
@@ -79,6 +79,7 @@ void main() {
expect(sut.appConfig.theme.mode, ThemeMode.system);
await MetadataRepository.refresh();
expect(sut.appConfig.theme.mode, ThemeMode.dark);
});
@@ -89,6 +90,7 @@ void main() {
expect(sut.appConfig.theme.mode, ThemeMode.dark);
await MetadataRepository.refresh();
expect(sut.appConfig.theme.mode, ThemeMode.system);
});
@@ -133,4 +135,5 @@ void main() {
await expectation;
});
});
}
@@ -3,7 +3,6 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';
LocalAsset createLocalAsset({
@@ -38,7 +37,6 @@ RemoteAsset createRemoteAsset({
DateTime? updatedAt,
DateTime? uploadedAt,
bool isFavorite = false,
DateTime? deletedAt,
}) {
return RemoteAsset(
id: 'remote-id',
@@ -52,7 +50,6 @@ RemoteAsset createRemoteAsset({
uploadedAt: uploadedAt ?? DateTime.now(),
isFavorite: isFavorite,
isEdited: false,
deletedAt: deletedAt,
);
}
@@ -461,62 +458,6 @@ void main() {
expect(ActionButtonType.trash.shouldShow(context), isFalse);
});
test('should not show when asset is already trashed', () {
final remoteAsset = createRemoteAsset(deletedAt: DateTime(2024));
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
timelineOrigin: TimelineOrigin.trash,
);
expect(ActionButtonType.trash.shouldShow(context), isFalse);
});
});
group('restoreTrash button', () {
test('should show when owner, not locked, has remote, and is in trash timeline', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
timelineOrigin: TimelineOrigin.trash,
);
expect(ActionButtonType.restoreTrash.shouldShow(context), isTrue);
});
test('should not show when not in trash timeline', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: false,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
timelineOrigin: TimelineOrigin.main,
);
expect(ActionButtonType.restoreTrash.shouldShow(context), isFalse);
});
});
group('deletePermanent button', () {
@@ -553,24 +494,6 @@ void main() {
expect(ActionButtonType.deletePermanent.shouldShow(context), isFalse);
});
test('should show when asset is trashed even with trash enabled', () {
final remoteAsset = createRemoteAsset(deletedAt: DateTime(2024));
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
timelineOrigin: TimelineOrigin.trash,
);
expect(ActionButtonType.deletePermanent.shouldShow(context), isTrue);
});
});
group('delete button', () {
+145 -90
View File
@@ -6,7 +6,7 @@ settings:
injectWorkspacePackages: true
overrides:
canvas: 3.2.3
canvas: 2.11.2
sharp: ^0.34.5
webpackbar: ^7.0.0
@@ -198,7 +198,7 @@ importers:
version: 6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest:
specifier: ^4.0.0
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
packages/cli:
dependencies:
@@ -289,7 +289,7 @@ importers:
version: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitest:
specifier: ^4.0.0
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest-fetch-mock:
specifier: ^0.4.0
version: 0.4.5(vitest@4.1.5)
@@ -663,7 +663,7 @@ importers:
version: 13.15.10
'@vitest/coverage-v8':
specifier: ^3.0.0
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
eslint:
specifier: ^10.0.0
version: 10.2.1(jiti@2.6.1)
@@ -717,7 +717,7 @@ importers:
version: 6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
web:
dependencies:
@@ -973,7 +973,7 @@ importers:
version: 8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitest:
specifier: ^4.0.0
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
packages:
@@ -6182,9 +6182,9 @@ packages:
caniuse-lite@1.0.30001790:
resolution: {integrity: sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==}
canvas@3.2.3:
resolution: {integrity: sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==}
engines: {node: ^18.12.0 || >= 20.9.0}
canvas@2.11.2:
resolution: {integrity: sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==}
engines: {node: '>=6'}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -6964,6 +6964,10 @@ packages:
decode-named-character-reference@1.2.0:
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
decompress-response@4.2.1:
resolution: {integrity: sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==}
engines: {node: '>=8'}
decompress-response@6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'}
@@ -7563,10 +7567,6 @@ packages:
resolution: {integrity: sha512-Yn66dSBaWGcUaSbm5Nl4G28rxtceLlWf4PstqJMbLix9sN7w0okWHPEvdudiP56Q5Cjl7v3TLyKKwowUFlbD8g==}
engines: {node: '>=20.0.0'}
expand-template@2.0.3:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
@@ -7866,9 +7866,6 @@ packages:
get-tsconfig@4.13.0:
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
github-from-package@0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
github-slugger@1.5.0:
resolution: {integrity: sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==}
@@ -8594,7 +8591,7 @@ packages:
resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==}
engines: {node: '>=18'}
peerDependencies:
canvas: 3.2.3
canvas: 2.11.2
peerDependenciesMeta:
canvas:
optional: true
@@ -9311,6 +9308,10 @@ packages:
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
engines: {node: '>=18'}
mimic-response@2.1.0:
resolution: {integrity: sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==}
engines: {node: '>=8'}
mimic-response@3.1.0:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'}
@@ -9478,9 +9479,6 @@ packages:
engines: {node: ^18 || >=20}
hasBin: true
napi-build-utils@2.0.0:
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
natural-compare-lite@1.4.0:
resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==}
@@ -9554,10 +9552,6 @@ packages:
no-case@3.0.4:
resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==}
node-abi@3.92.0:
resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==}
engines: {node: '>=10'}
node-abort-controller@3.1.1:
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
@@ -10505,12 +10499,6 @@ packages:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'}
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
hasBin: true
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -11188,8 +11176,8 @@ packages:
simple-concat@1.0.1:
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
simple-get@4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
simple-get@3.1.1:
resolution: {integrity: sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==}
simple-icons@16.17.0:
resolution: {integrity: sha512-bRrGtzM6NLgxeMWmRcfDdrRksECk101lRrCn6jjj6qzUB6lQ+E5smnr52rqS1kLPmbLpS/g6iF463j50M4BT7A==}
@@ -11898,9 +11886,6 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
tweetnacl@0.14.5:
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
@@ -15814,6 +15799,22 @@ snapshots:
'@mapbox/mapbox-gl-rtl-text@0.4.0': {}
'@mapbox/node-pre-gyp@1.0.11':
dependencies:
detect-libc: 2.1.2
https-proxy-agent: 5.0.1
make-dir: 3.1.0
node-fetch: 2.7.0
nopt: 5.0.0
npmlog: 5.0.1
rimraf: 3.0.2
semver: 7.7.4
tar: 6.2.1
transitivePeerDependencies:
- encoding
- supports-color
optional: true
'@mapbox/node-pre-gyp@1.0.11(encoding@0.1.13)':
dependencies:
detect-libc: 2.1.2
@@ -17247,7 +17248,7 @@ snapshots:
svelte: 5.55.2
optionalDependencies:
vite: 8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
dependencies:
@@ -17963,7 +17964,7 @@ snapshots:
'@vercel/oidc@3.0.5': {}
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
@@ -17978,7 +17979,7 @@ snapshots:
std-env: 3.10.0
test-exclude: 7.0.2
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
@@ -17994,7 +17995,7 @@ snapshots:
obug: 2.1.1
std-env: 4.1.0
tinyrainbow: 3.1.0
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/expect@3.2.4':
dependencies:
@@ -18761,10 +18762,24 @@ snapshots:
caniuse-lite@1.0.30001790: {}
canvas@3.2.3:
canvas@2.11.2:
dependencies:
node-addon-api: 7.1.1
prebuild-install: 7.1.3
'@mapbox/node-pre-gyp': 1.0.11
nan: 2.26.2
simple-get: 3.1.1
transitivePeerDependencies:
- encoding
- supports-color
optional: true
canvas@2.11.2(encoding@0.1.13):
dependencies:
'@mapbox/node-pre-gyp': 1.0.11(encoding@0.1.13)
nan: 2.26.2
simple-get: 3.1.1
transitivePeerDependencies:
- encoding
- supports-color
optional: true
ccount@2.0.1: {}
@@ -19567,6 +19582,11 @@ snapshots:
dependencies:
character-entities: 2.0.2
decompress-response@4.2.1:
dependencies:
mimic-response: 2.1.0
optional: true
decompress-response@6.0.0:
dependencies:
mimic-response: 3.1.0
@@ -20311,9 +20331,6 @@ snapshots:
optionalDependencies:
exiftool-vendored.exe: 13.58.0
expand-template@2.0.3:
optional: true
expect-type@1.3.0: {}
exponential-backoff@3.1.3: {}
@@ -20399,10 +20416,11 @@ snapshots:
fabric@7.3.1:
optionalDependencies:
canvas: 3.2.3
jsdom: 26.1.0(canvas@3.2.3)
canvas: 2.11.2
jsdom: 26.1.0(canvas@2.11.2)
transitivePeerDependencies:
- bufferutil
- encoding
- supports-color
- utf-8-validate
@@ -20694,9 +20712,6 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
github-from-package@0.0.0:
optional: true
github-slugger@1.5.0: {}
gl-matrix@3.4.4: {}
@@ -21518,7 +21533,7 @@ snapshots:
dependencies:
argparse: 2.0.1
jsdom@26.1.0(canvas@3.2.3):
jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)):
dependencies:
cssstyle: 4.6.0
data-urls: 5.0.0
@@ -21541,7 +21556,37 @@ snapshots:
ws: 8.20.0
xml-name-validator: 5.0.0
optionalDependencies:
canvas: 3.2.3
canvas: 2.11.2(encoding@0.1.13)
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
optional: true
jsdom@26.1.0(canvas@2.11.2):
dependencies:
cssstyle: 4.6.0
data-urls: 5.0.0
decimal.js: 10.6.0
html-encoding-sniffer: 4.0.0
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.23
parse5: 7.3.0
rrweb-cssom: 0.8.0
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 5.1.2
w3c-xmlserializer: 5.0.0
webidl-conversions: 7.0.0
whatwg-encoding: 3.1.1
whatwg-mimetype: 4.0.0
whatwg-url: 14.2.0
ws: 8.20.0
xml-name-validator: 5.0.0
optionalDependencies:
canvas: 2.11.2
transitivePeerDependencies:
- bufferutil
- supports-color
@@ -22544,6 +22589,9 @@ snapshots:
mimic-function@5.0.1: {}
mimic-response@2.1.0:
optional: true
mimic-response@3.1.0: {}
mimic-response@4.0.0: {}
@@ -22697,9 +22745,6 @@ snapshots:
nanoid@5.1.9: {}
napi-build-utils@2.0.0:
optional: true
natural-compare-lite@1.4.0: {}
natural-compare@1.4.0: {}
@@ -22772,11 +22817,6 @@ snapshots:
lower-case: 2.0.2
tslib: 2.8.1
node-abi@3.92.0:
dependencies:
semver: 7.7.4
optional: true
node-abort-controller@3.1.1: {}
node-addon-api@4.3.0: {}
@@ -22797,6 +22837,11 @@ snapshots:
emojilib: 2.4.0
skin-tone: 2.0.0
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
optional: true
node-fetch@2.7.0(encoding@0.1.13):
dependencies:
whatwg-url: 5.0.0
@@ -23763,22 +23808,6 @@ snapshots:
powershell-utils@0.1.0: {}
prebuild-install@7.1.3:
dependencies:
detect-libc: 2.1.2
expand-template: 2.0.3
github-from-package: 0.0.0
minimist: 1.2.8
mkdirp-classic: 0.5.3
napi-build-utils: 2.0.0
node-abi: 3.92.0
pump: 3.0.4
rc: 1.2.8
simple-get: 4.0.1
tar-fs: 2.1.4
tunnel-agent: 0.6.0
optional: true
prelude-ls@1.2.1: {}
prettier-linter-helpers@1.0.1:
@@ -24702,9 +24731,9 @@ snapshots:
simple-concat@1.0.1:
optional: true
simple-get@4.0.1:
simple-get@3.1.1:
dependencies:
decompress-response: 6.0.0
decompress-response: 4.2.1
once: 1.4.0
simple-concat: 1.0.1
optional: true
@@ -25546,11 +25575,6 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
tunnel-agent@0.6.0:
dependencies:
safe-buffer: 5.2.1
optional: true
tweetnacl@0.14.5: {}
type-check@0.4.0:
@@ -25960,9 +25984,9 @@ snapshots:
vitest-fetch-mock@0.4.5(vitest@4.1.5):
dependencies:
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
@@ -25991,7 +26015,7 @@ snapshots:
'@types/debug': 4.1.12
'@types/node': 24.12.2
happy-dom: 20.9.0
jsdom: 26.1.0(canvas@3.2.3)
jsdom: 26.1.0(canvas@2.11.2)
transitivePeerDependencies:
- jiti
- less
@@ -26006,7 +26030,7 @@ snapshots:
- tsx
- yaml
vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.5
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
@@ -26033,11 +26057,42 @@ snapshots:
'@types/node': 24.12.2
'@vitest/coverage-v8': 4.1.5(vitest@4.1.5)
happy-dom: 20.9.0
jsdom: 26.1.0(canvas@3.2.3)
jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13))
transitivePeerDependencies:
- msw
vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.5
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/pretty-format': 4.1.5
'@vitest/runner': 4.1.5
'@vitest/snapshot': 4.1.5
'@vitest/spy': 4.1.5
'@vitest/utils': 4.1.5
es-module-lexer: 2.1.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.4
std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.1.1
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
vite: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@types/node': 24.12.2
'@vitest/coverage-v8': 4.1.5(vitest@4.1.5)
happy-dom: 20.9.0
jsdom: 26.1.0(canvas@2.11.2)
transitivePeerDependencies:
- msw
vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.5
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
@@ -26064,7 +26119,7 @@ snapshots:
'@types/node': 25.6.0
'@vitest/coverage-v8': 4.1.5(vitest@4.1.5)
happy-dom: 20.9.0
jsdom: 26.1.0(canvas@3.2.3)
jsdom: 26.1.0(canvas@2.11.2)
transitivePeerDependencies:
- msw
+1 -1
View File
@@ -28,7 +28,7 @@ onlyBuiltDependencies:
- '@tailwindcss/oxide'
- bcrypt
overrides:
canvas: 3.2.3
canvas: 2.11.2
sharp: ^0.34.5
# pending docusaurus 3.10.1
webpackbar: ^7.0.0
+4 -7
View File
@@ -171,8 +171,8 @@ export class JobRepository {
options: this.getJobOptions(item) || undefined,
} as JobItem & { data: any; options: JobsOptions | undefined };
if (job.options?.jobId || job.options?.deduplication) {
// need to use add() instead of addBulk() for jobId/deduplication to take effect
if (job.options?.jobId) {
// need to use add() instead of addBulk() for jobId deduplication
promises.push(this.getQueue(queueName).add(item.name, item.data, job.options));
} else {
itemsByQueue[queueName] = itemsByQueue[queueName] || [];
@@ -230,13 +230,10 @@ export class JobRepository {
return { priority: 1 };
}
case JobName.FacialRecognitionQueueAll: {
return { deduplication: { id: JobName.FacialRecognitionQueueAll } };
return { jobId: JobName.FacialRecognitionQueueAll };
}
case JobName.VersionCheck: {
return { deduplication: { id: JobName.VersionCheck } };
}
case JobName.DatabaseBackup: {
return { deduplication: { id: JobName.DatabaseBackup } };
return { jobId: JobName.VersionCheck };
}
default: {
return null;
@@ -132,7 +132,7 @@ export class MachineLearningRepository {
private async check(url: string) {
let healthy = false;
try {
const response = await fetch(new URL('ping', url), {
const response = await fetch(new URL('/ping', url), {
signal: AbortSignal.timeout(this.config.availabilityChecks.timeout),
});
if (response.ok) {
@@ -170,7 +170,7 @@ export class MachineLearningRepository {
...this.config.urls.filter((url) => !this.isHealthy(url)),
]) {
try {
const response = await fetch(new URL('predict', url), { method: 'POST', body: formData });
const response = await fetch(new URL('/predict', url), { method: 'POST', body: formData });
if (response.ok) {
this.setHealthy(url, true);
return response.json();
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { BinaryField, DefaultReadTaskOptions, ExifTool, ReadTaskOptions, Tags } from 'exiftool-vendored';
import { BinaryField, DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored';
import geotz from 'geo-tz';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { mimeTypes } from 'src/utils/mime-types';
@@ -89,7 +89,7 @@ export class MetadataRepository {
geoTz: (lat, lon) => geotz.find(lat, lon)[0],
geolocation: true,
// Enable exiftool LFS to parse metadata for files larger than 2GB.
readArgs: ['-api', 'largefilesupport=1', '--ICC_Profile:DeviceManufacturer', '--ICC_Profile:DeviceModelName'],
readArgs: ['-api', 'largefilesupport=1'],
writeArgs: ['-api', 'largefilesupport=1', '-overwrite_original'],
taskTimeoutMillis: 2 * 60 * 1000,
});
@@ -107,8 +107,8 @@ export class MetadataRepository {
}
readTags(path: string): Promise<ImmichTags> {
const options: ReadTaskOptions | undefined = mimeTypes.isVideo(path) ? { readArgs: ['-ee'] } : undefined;
return this.exiftool.read(path, options).catch((error) => {
const args = mimeTypes.isVideo(path) ? ['-ee'] : [];
return this.exiftool.read(path, { readArgs: args }).catch((error) => {
this.logger.warn(`Error reading exif data (${path}): ${error}\n${error?.stack}`);
return {};
}) as Promise<ImmichTags>;
@@ -212,10 +212,16 @@
};
}
return {
fileCreatedAfter: dateAfter?.toUTC().toISO(),
fileCreatedBefore: dateBefore?.toUTC().toISO(),
};
try {
return {
fileCreatedAfter: dateAfter ? new Date(dateAfter).toISOString() : undefined,
fileCreatedBefore: dateBefore ? new Date(dateBefore).toISOString() : undefined,
};
} catch {
$mapSettings.dateAfter = '';
$mapSettings.dateBefore = '';
return {};
}
}
async function loadMapMarkers() {
@@ -231,7 +237,7 @@
{
isArchived: includeArchived || undefined,
isFavorite: onlyFavorites || undefined,
fileCreatedAfter,
fileCreatedAfter: fileCreatedAfter || undefined,
fileCreatedBefore,
withPartners: withPartners || undefined,
withSharedAlbums: withSharedAlbums || undefined,
+38 -27
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import DateInput from '$lib/elements/DateInput.svelte';
import type { MapSettings } from '$lib/stores/preferences.store';
import { Button, DatePicker, Field, FormModal, Select, Stack, Switch } from '@immich/ui';
import { Button, Field, FormModal, Select, Stack, Switch } from '@immich/ui';
import { Duration } from 'luxon';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
@@ -40,21 +41,29 @@
{#if customDateRange}
<div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4">
<Field label={$t('date_after')}>
<DatePicker bind:value={settings.dateAfter} maxDate={settings.dateBefore} />
</Field>
<Field label={$t('date_before')}>
<DatePicker bind:value={settings.dateBefore} />
</Field>
<div class="flex justify-center">
<div class="flex items-center justify-between gap-8">
<label class="shrink-0 text-sm immich-form-label" for="date-after">{$t('date_after')}</label>
<DateInput
class="immich-form-input w-40"
type="date"
id="date-after"
max={settings.dateBefore}
bind:value={settings.dateAfter}
/>
</div>
<div class="flex items-center justify-between gap-8">
<label class="shrink-0 text-sm immich-form-label" for="date-before">{$t('date_before')}</label>
<DateInput class="immich-form-input w-40" type="date" id="date-before" bind:value={settings.dateBefore} />
</div>
<div class="flex justify-center text-xs">
<Button
color="primary"
size="small"
variant="ghost"
onclick={() => {
customDateRange = false;
settings.dateAfter = undefined;
settings.dateBefore = undefined;
settings.dateAfter = '';
settings.dateBefore = '';
}}
>
{$t('remove_custom_date_range')}
@@ -62,7 +71,7 @@
</div>
</div>
{:else}
<div in:fly={{ y: -10, duration: 200 }} class="flex flex-col gap-2">
<div in:fly={{ y: -10, duration: 200 }} class="flex flex-col gap-1">
<Field label={$t('date_range')}>
<Select
bind:value={settings.relativeDate}
@@ -73,38 +82,40 @@
},
{
label: $t('past_durations.hours', { values: { hours: 24 } }),
value: Duration.fromObject({ hours: 24 }).toISO(),
value: Duration.fromObject({ hours: 24 }).toISO() || '',
},
{
label: $t('past_durations.days', { values: { days: 7 } }),
value: Duration.fromObject({ days: 7 }).toISO(),
value: Duration.fromObject({ days: 7 }).toISO() || '',
},
{
label: $t('past_durations.days', { values: { days: 30 } }),
value: Duration.fromObject({ days: 30 }).toISO(),
value: Duration.fromObject({ days: 30 }).toISO() || '',
},
{
label: $t('past_durations.years', { values: { years: 1 } }),
value: Duration.fromObject({ years: 1 }).toISO(),
value: Duration.fromObject({ years: 1 }).toISO() || '',
},
{
label: $t('past_durations.years', { values: { years: 3 } }),
value: Duration.fromObject({ years: 3 }).toISO(),
value: Duration.fromObject({ years: 3 }).toISO() || '',
},
]}
/>
</Field>
<Button
color="primary"
size="small"
variant="ghost"
onclick={() => {
customDateRange = true;
settings.relativeDate = '';
}}
>
{$t('use_custom_date_range')}
</Button>
<div class="text-xs">
<Button
color="primary"
size="small"
variant="ghost"
onclick={() => {
customDateRange = true;
settings.relativeDate = '';
}}
>
{$t('use_custom_date_range')}
</Button>
</div>
</div>
{/if}
</Stack>
@@ -1,9 +1,9 @@
<script lang="ts">
import DateInput from '$lib/elements/DateInput.svelte';
import { handleUpdatePersonBirthDate } from '$lib/services/person.service';
import { type PersonResponseDto } from '@immich/sdk';
import { Button, DatePicker, Field, FormModal, HelperText } from '@immich/ui';
import { Button, FormModal, Text } from '@immich/ui';
import { mdiCake } from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
type Props = {
@@ -12,25 +12,32 @@
};
let { person, onClose }: Props = $props();
let birthDate = $derived(person.birthDate ? DateTime.fromISO(person.birthDate) : undefined);
let birthDate = $derived(person.birthDate ?? '');
const onSubmit = async () => {
const success = await handleUpdatePersonBirthDate(person, birthDate?.toISODate() ?? '');
const success = await handleUpdatePersonBirthDate(person, birthDate);
if (success) {
onClose();
}
};
const todayFormatted = new Date().toISOString().split('T')[0];
</script>
<FormModal title={$t('set_date_of_birth')} size="small" icon={mdiCake} {onClose} {onSubmit}>
<div class="my-2 flex flex-col gap-2">
<Field label={$t('date_of_birth')}>
<DatePicker bind:value={birthDate} maxDate={DateTime.now()} />
<HelperText>{$t('birthdate_set_description')}</HelperText>
</Field>
<Text size="small">{$t('birthdate_set_description')}</Text>
<div class="my-4 flex flex-col gap-2">
<DateInput
class="immich-form-input"
id="birthDate"
name="birthDate"
type="date"
bind:value={birthDate}
max={todayFormatted}
/>
{#if person.birthDate}
<div class="flex justify-end">
<Button shape="round" color="secondary" size="small" onclick={() => (birthDate = undefined)}>
<Button shape="round" color="secondary" size="small" onclick={() => (birthDate = '')}>
{$t('clear')}
</Button>
</div>
+4 -3
View File
@@ -1,4 +1,3 @@
import type { DateTime } from 'luxon';
import { persisted } from 'svelte-persisted-store';
import { browser } from '$app/environment';
import { defaultLang } from '$lib/constants';
@@ -27,8 +26,8 @@ export interface MapSettings {
withPartners: boolean;
withSharedAlbums: boolean;
relativeDate: string;
dateAfter?: DateTime<true>;
dateBefore?: DateTime<true>;
dateAfter: string;
dateBefore: string;
}
const defaultMapSettings = {
@@ -38,6 +37,8 @@ const defaultMapSettings = {
withPartners: false,
withSharedAlbums: false,
relativeDate: '',
dateAfter: '',
dateBefore: '',
};
const persistedObject = <T>(key: string, defaults: T) =>
+4 -33
View File
@@ -4,18 +4,15 @@
import OnEvents from '$lib/components/OnEvents.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
import SingleGridRow from '$lib/components/shared-components/SingleGridRow.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { Route } from '$lib/route';
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { getAssetInfo, AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import { mdiHeart } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAltText } from '$lib/utils/thumbnail-util';
import Portal from '$lib/elements/Portal.svelte';
interface Props {
data: PageData;
@@ -43,15 +40,6 @@
}
}
};
const onViewAsset = async (id: string) => {
const asset = await getAssetInfo({ ...authManager.params, id });
assetViewerManager.setAsset(asset);
};
const assetCursor = $derived({
current: assetViewerManager.asset!,
});
</script>
<OnEvents {onPersonThumbnailReady} />
@@ -134,20 +122,15 @@
draggable="false">{$t('view_all')}</a
>
</div>
<div class="flex h-24 max-w-fit flex-wrap gap-x-1 overflow-hidden md:h-42">
<div class="flex h-24 flex-wrap gap-x-1 overflow-hidden md:h-42">
{#each recents as item (item.data.id)}
<button
type="button"
class="relative h-full flex-auto"
onclick={() => onViewAsset(item.data.id)}
draggable="false"
>
<a class="relative h-full flex-auto" href={Route.viewAsset({ id: item.data.id })} draggable="false">
<img
src={getAssetMediaUrl({ id: item.data.id, size: AssetMediaSize.Thumbnail })}
alt={$getAltText(toTimelineAsset(item.data))}
class="size-full min-w-max rounded-xl object-cover"
/>
</button>
</a>
{/each}
</div>
</div>
@@ -157,15 +140,3 @@
<EmptyPlaceholder text={$t('no_explore_results_message')} class="mx-auto mt-10" />
{/if}
</UserPageLayout>
{#if assetViewerManager.isViewing}
{#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }}
<Portal target="body">
<AssetViewer
cursor={assetCursor}
showNavigation={false}
onClose={() => assetViewerManager.showAssetViewer(false)}
/>
</Portal>
{/await}
{/if}