Compare commits

...

84 Commits

Author SHA1 Message Date
Alex
536fda04f2 Up version for release 2022-10-04 15:29:47 -05:00
Alex
2094204877 Up version for release 2022-10-04 15:29:37 -05:00
Alex
ab375cca1a Up Version for release 2022-10-04 15:21:58 -05:00
Alex
479f706f8a fix(mobile): Fix error parsing datetime prevent the timeline to be displayed (#784) 2022-10-04 15:19:29 -05:00
Deepesh Bhardwaj
4342285507 Updated jpeg thumbnail path (#780) 2022-10-04 09:46:06 -05:00
Jonas Janz
8bb656cb17 add docker volumes to services (#766)
* add docker volumes to services

this change adds the volume definitions for
/usr/src/app/upload
/usr/src/app/.reverse-geocoding-dump

to the `immich-server` docker-compose files
as /usr/src/app/upload should always be a volume for the containers
I also added it to the `Dockerfile`

Signed-off-by: PixelJonas <5434875+PixelJonas@users.noreply.github.com>

* remove geocoding-dump volume from docker-compose

Signed-off-by: PixelJonas <5434875+PixelJonas@users.noreply.github.com>
2022-10-01 16:01:27 -05:00
Alex
3f1f835df3 Update readme for beta release invitation links 2022-09-29 15:13:18 -05:00
Matthias Rupp
87ca031335 Fix bug with missing year and add date to drag handle (#761) 2022-09-29 10:19:55 -05:00
Alex Tran
96b9e37461 Up version for release 2022-09-28 16:28:14 -05:00
Alex Tran
0d3a2fe844 Added generated geocoding files to gitignore 2022-09-28 15:44:43 -05:00
Johannes Zellner
848781aef5 Provide a sensible dumpDirectory for the local-reverse-geocoder module (#759)
Fixes #758
2022-09-28 15:43:34 -05:00
Matthias Rupp
28bf497a0b feat(mobile): Improve timeline performance on mobile - experimental (#710) 2022-09-28 11:30:38 -05:00
Alex Tran
8ede738396 Up mobile version 2022-09-28 06:22:12 -05:00
Alex Tran
40c2b6a563 Update readme 2022-09-28 05:59:23 -05:00
Alex Tran
3581cf7305 Pump server version 2022-09-28 05:53:50 -05:00
Zack Pollard
c33775b944 feat(server): missing exif extract nightly task (#754)
* fix: nightly reverse geocoding task checking for mapbox

* refactor: remove file size from image processor and queue data

* feat: add missing exif nightly job

* Remove filesize requirement in assetUploadedProcessorName queue insertion

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2022-09-28 05:41:50 -05:00
Alex
b0cd2522e0 feat(server): support .NEF file (#746) 2022-09-23 19:09:45 -05:00
Alex
c3979f6e31 fix(machine-learning) Remove unsused database config (#745) 2022-09-23 19:00:47 -05:00
Alex
103df4d9f3 fix(web) navigating forward button get in the way of video control bar (#744)
* fix(web) navigating forward button get in the way of video control bar

* Remove unsued style
2022-09-23 18:22:06 -05:00
Zack Pollard
040e02cfc5 fix(server): handle missing reverse geocoding admin zones (#742) 2022-09-23 10:14:42 -05:00
Zack Pollard
f377b64065 feat(server) Remove mapbox and use local reverse geocoding (#738)
* feat: local reverse geocoding implementation, removes mapbox

* Disable non-null tslintrule

* Disable non-null tslintrule

* Remove tsignore

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2022-09-22 21:50:05 -05:00
Alex
e5459b68ff fix(server,web,mobile): Incorrectly record and show timestamp and time zone of the asset (#706)
Implemented a mechanism to extract the correct time zone from the GPS coordinate if presented in the file's EXIF, and to convert the timestamp to the correct UTC time so that the time will show correctly based on the mobile/web local time zone.
2022-09-22 15:58:17 -05:00
Alex Tran
fc194021a4 Pump server version 2022-09-22 11:38:50 -05:00
bo0tzz
39f8ca3bf1 Only run scheduled geocoding task once per day (#730) 2022-09-21 07:17:59 -05:00
Alex
7a807f7216 Update README.md 2022-09-19 16:04:22 -05:00
Alex
bedfb51b1c Add demo URL 2022-09-19 16:00:51 -05:00
Alex
b2afb95c19 Create codeql-analysis.yml 2022-09-19 14:03:49 -05:00
Alex
10239161fd fix(mobile): app crash when there is no object detection result on search page (#725)
* fix(mobile): app crash when there is no data for object detection

* Up version for release

* Up version for release
2022-09-19 11:03:51 -05:00
Alex
242f10952d fix(server): query only image (#724) 2022-09-19 10:48:44 -05:00
Alex Tran
e997bd371b Up server version 2022-09-18 21:44:55 -05:00
Alex
400167f4ef fix(server): sanitization error that crash the server (#721) 2022-09-18 21:44:13 -05:00
Alex
572f6d833d Up mobile version and update deprecated api 2022-09-18 16:11:30 -05:00
Alex
2e06be5155 Up mobile version and update deprecated api 2022-09-18 16:11:24 -05:00
Alex Tran
62121470a8 Up server version 2022-09-18 15:37:10 -05:00
Alex
e3ccc3ee6b feat(server): sanitized path for asset creation process to avoid security risk (#717)
* feat(server): sanitized path for asset creation process to avoid security risk

* Sanitize resize path
2022-09-18 15:16:53 -05:00
Alex
ece94f6bdc fix(server): correct user permission to update user info (#716) 2022-09-18 09:27:06 -05:00
Jamie Slome
03fc0703c0 Create SECURITY.md (#712) 2022-09-17 13:07:12 -05:00
Alex
0d13b25f56 feat(web): Update to latest version of SvelteKit (#705) 2022-09-16 23:13:22 -05:00
Alex
75c2067836 feat(web) Remove fetching fonts from GoogleFonts (#703) 2022-09-16 17:23:31 -05:00
Alex
824da6a07b Up server version 2022-09-16 16:55:04 -05:00
Alex
2c2ea24dc4 test(web) Add tests for asset repository (#680)
* Added back tests for asset repository

* Added more tests

* Added asset count test
2022-09-16 16:47:45 -05:00
Alex
47b73a5b64 fix(mobile): Fixed iOS 16 overflow cache and memory leaked in gallery viewer. (#700) 2022-09-16 16:46:23 -05:00
bo0tzz
6b3f8e548d Merge pull request #699 from JaCoB1123/patch-1
Fix spelling of Proxmox in Readme
2022-09-15 23:07:00 +02:00
Jan Bader
0ea483f901 Fix spelling of Proxmox in Readme 2022-09-15 23:05:15 +02:00
Jonas Janz
97aed8ef23 fix(nginx): revert nginx image to support arm/v7 (#692) 2022-09-14 13:36:29 -05:00
Alex
0ee3fe9157 Update install.sh to use latest released tag 2022-09-14 11:07:37 -05:00
Alex
434770155f Up version for release 2022-09-14 10:27:34 -05:00
Alex
7e8bf94543 fix/cache read write error ios16 (#691)
* Fix(mobile) cache read/write issue, cannot load image on ios16

* Update
2022-09-14 10:18:25 -05:00
Zack Pollard
8d8944705c Merge pull request #690 from beune/fix-typo
Fix typo
2022-09-14 13:16:47 +01:00
Pim Beune
7c9c1a5169 Fix typo 2022-09-14 13:53:34 +02:00
Jonas Janz
1a6c16d8ea breaking(setup): use non-root image for immich-proxy (#651)
* feat(nginx): use non-root container for immich-proxy

Signed-off-by: PixelJonas <5434875+PixelJonas@users.noreply.github.com>

* re-add test env

* feat(nginx): add correct port for staging

* add the new port to the default docker-compose.yml

Signed-off-by: PixelJonas <5434875+PixelJonas@users.noreply.github.com>
2022-09-13 21:50:10 -05:00
Alex
ccf792f9d3 fix(server): mismatch createdAt value in table and table (#688) 2022-09-13 20:12:42 -05:00
Fynn Petersen-Frey
789bc8563c fix Android BackgroundServiceStartNotAllowedException (#687) 2022-09-13 20:12:31 -05:00
Manuel
99a50f70dd readme: add app store links (#689) 2022-09-13 18:23:27 -05:00
Alex Tran
9bef411056 Up server version: 2022-09-13 12:14:36 -05:00
Alex
e79e92c60f Added Log level to background service (#685) 2022-09-13 12:09:57 -05:00
Alex
858ad43d3b fix(server): harden inserting process, self-healing timestamp info on bad timestamp (#682)
* fix(server): harden inserting process, self-healing timestamp info
2022-09-12 23:35:44 -05:00
Alex
5761765ea7 fix(server): remove album thumbnail when the asset is deleted from the database (#681) 2022-09-12 22:06:52 -05:00
Thanh Pham
6abc733763 fix(web): datetime display and add TZ into environment (#618)
* fix(web): timezone

* doc(): update readme.md

* feat(web): keep using UTC timezone in default

* chore(): update doc and remove debug code

* chore(): update readme.md

* Move timezone into to .env.example

* Run prettier check

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2022-09-12 14:40:18 -05:00
Alex Tran
4271e24e59 Up version for release 2022-09-11 16:05:53 -05:00
Alex
9e4ed2214b fix(web): incorrect shared album count (#677) 2022-09-11 10:07:04 -05:00
Alex
011332e509 fix(mobile) memory leaked causes app to crash when swiping (#673)
* Dispose image provider when swiping away from the asset
2022-09-11 09:56:26 -05:00
Alex
5403ef4d84 Fix(mobile) oversize play button (#672) 2022-09-11 00:25:04 -05:00
Alex Tran
31739aca02 Up version for release 2022-09-10 11:58:59 -05:00
Thanh Pham
8f2e7b6f65 fix(server): loop on checksum generation (#662) 2022-09-10 11:52:39 -05:00
Brett Profitt
4ed647c43d fix(install): Fix checking for docker compose. (#663) 2022-09-10 11:48:50 -05:00
Alex
f88ff4fb5c fix(mobile): background backup not working in release mode (#664) 2022-09-10 11:46:51 -05:00
Alex Tran
cc4881d633 Up version for release 2022-09-09 23:23:37 -05:00
Alex
d856b35afc feat(web) add scrollbar with timeline information (#658)
- Implement a scrollbar with a timeline similar to Google Photos
- The scrollbar can also be dragged
2022-09-09 15:55:20 -05:00
Jaime Baez
b6d025da09 Fix Notification components possible memory leaks (#650)
Dispose subscriptions and timeouts when
the components are removed from the DOM
2022-09-09 07:40:35 -05:00
Jaime Baez
cc79ff1ca3 Merge pull request #642 from immich-app/add/ci-web-checks
Add web test / check commands and workflow to run in CI
2022-09-08 19:12:39 +02:00
Jaime Baez
131aa2b6be Add command to test/check code in dev-setup docs 2022-09-08 17:54:45 +02:00
Jaime Baez
02a6b73122 Add web-unit-test workflow to run in CI 2022-09-08 17:44:13 +02:00
Jaime Baez
d87366c095 Add dev-setup documentation 2022-09-08 17:41:24 +02:00
Jaime Baez
4f7a3afbfc Fix web lint issues 2022-09-08 17:30:49 +02:00
Jaime Baez
6725954b70 Add web check / lint npm commands
`svelte-check` returns some "hints" that can be ignored since some
are not true and others are not relevant.
2022-09-08 17:17:15 +02:00
Fynn Petersen-Frey
4fe535e5e8 improve Android background service reliability (#603)
This change greatly reduces the chance that a backup is not performed
when a new photo/video is made.
Instead of combining the change trigger and additonal constraints (wifi
or charging) into a single worker, these aspects are now separated.
Thus, it is now reliably possible to take pictures while the wifi
constraint is not satisfied and upload them hours/days later once
connected to wifi without taking a new photo.
As a positive side effect, this simplifies the error/retry handling
by directly leveraging Android's WorkManager without workarounds.
The separation also allows to notify the currently running BackupWorker
that new assets were added while backing up other assets to also upload
those newly added assets.
Further, a new tiny service checks if the app is killed, to reschedule
the content change worker and allow to detect the first new photo.
Bonus: The home screen now shows backup as enabled if background backup
is active.

* use separate worker/task for listening on changed/added assets
* use separate worker/task for performing the backup
* content observer worker enqueues backup worker on each new asset
* wifi/charging constraints only apply to backup worker
* backupworker is notified of assets added while running to re-run
* new service to catch app being killed to workaround WorkManager issue
2022-09-08 08:36:08 -05:00
Jaime Baez
aed94bfc4c Format web code with prettier
Added `.md` and `.json` to .prettierignore
2022-09-08 12:53:09 +02:00
Jaime Baez
de996c0a81 Merge pull request #612 from immich-app/add/web-ui-tests-setup
Add web UI components tests setup

@alextran1502 I'll get this merged so I can add CI checks for the web as well. Let me know if you have any questions 😃
2022-09-08 11:24:08 +02:00
Jaime Baez
1a39aa4da5 Merge pull request #633 from immich-app/fix/server-lint-errors
Add all server checks to CI - fix lint issues
2022-09-08 11:12:31 +02:00
Jaime Baez
1f4ba73da7 Add all server checks to CI - fix lint issues
CI will now run linter, type-checks and tests for the server.

All the lint issues have been fixed.
2022-09-08 11:07:27 +02:00
Alex Tran
836b174d33 Better styling for count info 2022-09-07 21:19:24 -05:00
Jaime Baez
6b702b13e4 Rename albums BLoC (.bloc.ts convention)
By convention now it's `album.bloc.ts`
2022-09-07 16:04:50 +02:00
Jaime Baez
f476bd985b Add AlbumCard UI tests
- add libraries for component UI testing
- implement AlbumCard UI tests
2022-09-07 16:00:57 +02:00
195 changed files with 11197 additions and 7016 deletions

74
.github/workflows/codeql-analysis.yml vendored Normal file
View File

@@ -0,0 +1,74 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ "main" ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ "main" ]
schedule:
- cron: '20 13 * * 1'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript', 'python' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
with:
category: "/language:${{matrix.language}}"

View File

@@ -18,8 +18,8 @@ jobs:
- name: Run Immich Server 2E2 Test - name: Run Immich Server 2E2 Test
run: docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test up --abort-on-container-exit --exit-code-from immich-server-test run: docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test up --abort-on-container-exit --exit-code-from immich-server-test
unit-tests: server-unit-tests:
name: Run unit test suites name: Run server unit test suites and checks
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -27,4 +27,15 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Run tests - name: Run tests
run: cd server && npm install && npm run test run: cd server && npm ci && npm run check:all
web-unit-tests:
name: Run web unit test suites and checks
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Run tests
run: cd web && npm ci && npm run check:all

View File

@@ -23,11 +23,30 @@
<br/> <br/>
</p> </p>
## Demo
You can access the web demo at https://demo.immich.app
For the mobile app, you can use https://demo.immich.app/api for the `Server Endpoint URL`
```
The credential
email: demo@immich.app
password: demo
```
```
Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
```
## Content ## Content
- [Features](#features) - [Features](#features)
- [Screenshots](#screenshots) - [Screenshots](#screenshots)
- [Installation](#installation) - [Installation](#installation)
- [Mobile App](#-mobile-app) - [Update](#update)
- [Mobile App](#mobile-app)
- [App Beta Invitation links](#App-Beta-release-channel)
- [Development](#development) - [Development](#development)
- [Support](#support) - [Support](#support)
- [Known Issues](#known-issues) - [Known Issues](#known-issues)
@@ -145,7 +164,6 @@ wget -O .env https://raw.githubusercontent.com/immich-app/immich/main/docker/.en
* Populate custom database information if necessary. * Populate custom database information if necessary.
* Populate `UPLOAD_LOCATION` as prefered location for storing backup assets. * Populate `UPLOAD_LOCATION` as prefered location for storing backup assets.
* Populate a secret value for `JWT_SECRET`, you can use this command: `openssl rand -base64 128` * Populate a secret value for `JWT_SECRET`, you can use this command: `openssl rand -base64 128`
* [Optional] Populate Mapbox value to use reverse geocoding.
### Step 3 - Start the containers ### Step 3 - Start the containers
@@ -172,15 +190,27 @@ wget -O .env https://raw.githubusercontent.com/immich-app/immich/main/docker/.en
<br/> <br/>
## Update
If you have installed, you can update the application by navigate to the directory that contains the `docker-compose.yml` file and run the following command:
```bash
docker-compose pull && docker-compose up -d
```
# Mobile app # Mobile app
| F-Droid | Google Play | iOS | | F-Droid | Google Play | iOS |
| - | - | - | | - | - | - |
| <a href="https://f-droid.org/packages/app.alextran.immich"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80"></a> | <p align="left"> <img src="design/google-play-qr-code.png" width="200" title="Google Play Store"> <p/> | <p align="left"> <img src="design/ios-qr-code.png" width="200" title="Apple App Store"> <p/> | | <a href="https://f-droid.org/packages/app.alextran.immich"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80"></a> | <p align="left"> <a href="https://play.google.com/store/apps/details?id=app.alextran.immich"><img src="design/google-play-qr-code.png" width="200" title="Google Play Store"></a> <p/> | <p align="left"> <a href="https://apps.apple.com/us/app/immich/id1613945652"><img src="design/ios-qr-code.png" width="200" title="Apple App Store"></a> <p/> |
> *The App version might be lagging behind the latest release due to the review process.* > *The Play/App Store version might be lagging behind the latest release due to the review process.*
# App Beta release channel
You can opt-in to join app beta release channel by following the links below:
* Android: Invitation link from [web](https://play.google.com/store/apps/details?id=app.alextran.immich) or from [mobile](https://play.google.com/store/apps/details?id=app.alextran.immich)
* iOS: [TestFlight invitation link](https://testflight.apple.com/join/1vYsAa8P)
<br/> <br/>
# Development # Development
@@ -227,7 +257,7 @@ Cheers! 🎉
## TensorFlow Build Issue ## TensorFlow Build Issue
*This is a known issue for incorrect Promox setup* *This is a known issue for incorrect Proxmox setup*
TensorFlow doesn't run with older CPU architecture, it requires a CPU with AVX and AVX2 instruction set. If you encounter the error `illegal instruction core dump` when running the docker-compose command above, check for your CPU flags with the command and make sure you see `AVX` and `AVX2`: TensorFlow doesn't run with older CPU architecture, it requires a CPU with AVX and AVX2 instruction set. If you encounter the error `illegal instruction core dump` when running the docker-compose command above, check for your CPU flags with the command and make sure you see `AVX` and `AVX2`:
@@ -235,7 +265,7 @@ TensorFlow doesn't run with older CPU architecture, it requires a CPU with AVX a
more /proc/cpuinfo | grep flags more /proc/cpuinfo | grep flags
``` ```
If you are running virtualization in Promox, the VM doesn't have the flag enabled. If you are running virtualization in Proxmox, the VM doesn't have the flag enabled.
You need to change the CPU type from `kvm64` to `host` under VMs hardware tab. You need to change the CPU type from `kvm64` to `host` under VMs hardware tab.

5
SECURITY.md Normal file
View File

@@ -0,0 +1,5 @@
# Security Policy
## Reporting a Vulnerability
Please report security issues to `alex.tran1502@gmail.com`

32
dev-setup.md Normal file
View File

@@ -0,0 +1,32 @@
# Development Setup
## Lint / format extensions
Setting these in the IDE give a better developer experience auto-formatting code on save and providing instant feedback on lint issues.
### VSCode
Install Prettier, ESLint and Svelte extensions.
in User `settings.json` (`cmd + shift + p` and search for Open User Settings JSON) add the following:
```json
{
"editor.formatOnSave": true,
"[javascript][typescript][css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.tabSize": 2,
"editor.formatOnSave": true
},
"[svelte]": {
"editor.defaultFormatter": "svelte.svelte-vscode",
"editor.tabSize": 2
},
"svelte.enable-ts-plugin": true,
"eslint.validate": ["javascript", "svelte"]
}
```
## Running tests / checks
In both server and web:
`npm run check:all`

View File

@@ -10,9 +10,6 @@ DB_DATABASE_NAME=immich
# Optional Database settings: # Optional Database settings:
# DB_PORT=5432 # DB_PORT=5432
################################################################################### ###################################################################################
# Redis # Redis
################################################################################### ###################################################################################
@@ -25,18 +22,17 @@ REDIS_HOSTNAME=immich_redis
# REDIS_PASSWORD= # REDIS_PASSWORD=
# REDIS_SOCKET= # REDIS_SOCKET=
################################################################################### ###################################################################################
# Upload File Config # Upload File Config
################################################################################### ###################################################################################
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
###################################################################################
# Log message level - [simple|verbose]
###################################################################################
LOG_LEVEL=simple
################################################################################### ###################################################################################
# JWT SECRET # JWT SECRET
@@ -44,17 +40,21 @@ UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_ba
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
################################################################################### ###################################################################################
# MAPBOX # Reverse Geocoding
#################################################################################### ####################################################################################
# ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY # DISABLE_REVERSE_GEOCODING=false
ENABLE_MAPBOX=false
MAPBOX_KEY=
# Reverse geocoding is done locally which has a small impact on memory usage
# This memory usage can be altered by changing the REVERSE_GEOCODING_PRECISION variable
# This ranges from 0-3 with 3 being the most precise
# 3 - Cities > 500 population: ~200MB RAM
# 2 - Cities > 1000 population: ~150MB RAM
# 1 - Cities > 5000 population: ~80MB RAM
# 0 - Cities > 15000 population: ~40MB RAM
# REVERSE_GEOCODING_PRECISION=3
#################################################################################### ####################################################################################
# WEB - Optional # WEB - Optional

View File

@@ -102,8 +102,7 @@ services:
context: ../nginx context: ../nginx
dockerfile: Dockerfile dockerfile: Dockerfile
ports: ports:
- 2283:80 - 2283:8080
- 2284:443
logging: logging:
driver: none driver: none
depends_on: depends_on:

View File

@@ -72,8 +72,7 @@ services:
container_name: immich_proxy container_name: immich_proxy
image: altran1502/immich-proxy:staging image: altran1502/immich-proxy:staging
ports: ports:
- 2283:80 - 2283:8080
- 2284:443
logging: logging:
driver: none driver: none
depends_on: depends_on:

View File

@@ -72,7 +72,7 @@ services:
container_name: immich_proxy container_name: immich_proxy
image: altran1502/immich-proxy:release image: altran1502/immich-proxy:release
ports: ports:
- 2283:80 - 2283:8080
logging: logging:
driver: none driver: none
depends_on: depends_on:

View File

@@ -2,12 +2,17 @@ echo "Starting Immich installation..."
ip_address=$(hostname -I | awk '{print $1}') ip_address=$(hostname -I | awk '{print $1}')
release_version=$(curl --silent "https://api.github.com/repos/immich-app/immich/releases/latest" |
grep '"tag_name":' |
sed -E 's/.*"([^"]+)".*/\1/')
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\032[0;31m' GREEN='\032[0;31m'
NC='\033[0m' # No Color NC='\033[0m' # No Color
machine_has() { get_release_version() {
type "$1" >/dev/null 2>&1 curl --silent "https://api.github.com/repos/immich-app/immich/releases/latest" | # Get latest release from GitHub api
grep '"tag_name":' | # Get tag line
sed -E 's/.*"([^"]+)".*/\1/' # Pluck JSON value
} }
create_immich_directory() { create_immich_directory() {
@@ -17,12 +22,12 @@ create_immich_directory() {
download_docker_compose_file() { download_docker_compose_file() {
echo "Downloading docker-compose.yml..." echo "Downloading docker-compose.yml..."
curl -L https://raw.githubusercontent.com/immich-app/immich/main/docker/docker-compose.yml -o ./immich-app/docker-compose.yml >/dev/null 2>&1 curl -L https://raw.githubusercontent.com/immich-app/immich/$release_version/docker/docker-compose.yml -o ./immich-app/docker-compose.yml >/dev/null 2>&1
} }
download_dot_env_file() { download_dot_env_file() {
echo "Downloading .env file..." echo "Downloading .env file..."
curl -L https://raw.githubusercontent.com/immich-app/immich/main/docker/.env.example -o ./immich-app/.env >/dev/null 2>&1 curl -L https://raw.githubusercontent.com/immich-app/immich/$release_version/docker/.env.example -o ./immich-app/.env >/dev/null 2>&1
} }
populate_upload_location() { populate_upload_location() {
@@ -45,18 +50,21 @@ populate_upload_location() {
start_docker_compose() { start_docker_compose() {
echo "Starting Immich's docker containers" echo "Starting Immich's docker containers"
if machine_has "docker compose"; then { if docker compose &>/dev/null; then
docker compose up --remove-orphans -d docker_bin="docker compose"
elif docker-compose &>/dev/null; then
show_friendly_message docker_bin="docker-compose"
exit 0 else
}; fi echo 'Cannot find `docker compose` or `docker-compose`.'
exit 1
if machine_has "docker-compose"; then fi
docker-compose up --remove-orphans -d
if $docker_bin up --remove-orphans -d; then
show_friendly_message show_friendly_message
exit 0 exit 0
else
echo "Could not start. Check for errors above."
exit 1
fi fi
} }
@@ -65,7 +73,7 @@ show_friendly_message() {
echo "You can access the website at http://$ip_address:2283 and the server URL for the mobile app is http://$ip_address:2283/api" echo "You can access the website at http://$ip_address:2283 and the server URL for the mobile app is http://$ip_address:2283/api"
echo "The backup (or upload) location is $upload_location" echo "The backup (or upload) location is $upload_location"
echo "---------------------------------------------------" echo "---------------------------------------------------"
echo "If you want to confgure custom information of the server, including the database, Redis information, or the backup (or upload) location, etc. echo "If you want to configure custom information of the server, including the database, Redis information, or the backup (or upload) location, etc.
1. First bring down the containers with the command 'docker-compose down' in the immich-app directory, 1. First bring down the containers with the command 'docker-compose down' in the immich-app directory,

File diff suppressed because it is too large Load Diff

View File

@@ -25,7 +25,6 @@
"@nestjs/core": "^8.0.0", "@nestjs/core": "^8.0.0",
"@nestjs/mapped-types": "^1.0.1", "@nestjs/mapped-types": "^1.0.1",
"@nestjs/platform-express": "^8.0.0", "@nestjs/platform-express": "^8.0.0",
"@nestjs/typeorm": "^8.0.3",
"@tensorflow-models/coco-ssd": "^2.2.2", "@tensorflow-models/coco-ssd": "^2.2.2",
"@tensorflow-models/mobilenet": "^2.1.0", "@tensorflow-models/mobilenet": "^2.1.0",
"@tensorflow/tfjs": "^3.19.0", "@tensorflow/tfjs": "^3.19.0",
@@ -34,11 +33,9 @@
"@tensorflow/tfjs-node": "^3.19.0", "@tensorflow/tfjs-node": "^3.19.0",
"@tensorflow/tfjs-node-gpu": "^3.19.0", "@tensorflow/tfjs-node-gpu": "^3.19.0",
"@trpc/server": "^9.20.3", "@trpc/server": "^9.20.3",
"pg": "^8.7.3",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"rxjs": "^7.2.0", "rxjs": "^7.2.0"
"typeorm": "^0.2.45"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^8.2.4", "@nestjs/cli": "^8.2.4",

View File

@@ -1,15 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ImageClassifierModule } from './image-classifier/image-classifier.module'; import { ImageClassifierModule } from './image-classifier/image-classifier.module';
import { databaseConfig } from './config/database.config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ObjectDetectionModule } from './object-detection/object-detection.module'; import { ObjectDetectionModule } from './object-detection/object-detection.module';
@Module({ @Module({
imports: [ imports: [ImageClassifierModule, ObjectDetectionModule],
TypeOrmModule.forRoot(databaseConfig),
ImageClassifierModule,
ObjectDetectionModule,
],
controllers: [], controllers: [],
providers: [], providers: [],
}) })

View File

@@ -1,11 +0,0 @@
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
export const databaseConfig: TypeOrmModuleOptions = {
type: 'postgres',
host: process.env.DB_HOSTNAME || 'immich_postgres',
port: parseInt(process.env.DB_PORT || '5432'),
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE_NAME,
synchronize: false,
};

View File

@@ -12,6 +12,7 @@
</intent-filter> </intent-filter>
</activity> </activity>
<service android:name=".AppClearedService" android:stopWithTask="false" />
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data android:name="flutterEmbedding" android:value="2" /> <meta-data android:name="flutterEmbedding" android:value="2" />

View File

@@ -0,0 +1,25 @@
package app.alextran.immich
import android.app.Service
import android.content.Intent
import android.os.IBinder
/**
* Catches the event when either the system or the user kills the app
* (does not apply on force close!)
*/
class AppClearedService() : Service() {
override fun onBind(intent: Intent): IBinder? {
return null
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
return START_NOT_STICKY;
}
override fun onTaskRemoved(rootIntent: Intent) {
ContentObserverWorker.workManagerAppClearedWorkaround(applicationContext)
stopSelf();
}
}

View File

@@ -1,11 +1,6 @@
package app.alextran.immich package app.alextran.immich
import android.content.Context import android.content.Context
import android.net.Uri
import android.content.Intent
import android.provider.Settings
import android.util.Log
import android.widget.Toast
import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
@@ -44,30 +39,30 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
val ctx = context!! val ctx = context!!
when(call.method) { when(call.method) {
"initialize" -> { // needs to be called prior to any other method "enable" -> {
val args = call.arguments<ArrayList<*>>()!! val args = call.arguments<ArrayList<*>>()!!
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit().putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long).apply() .edit()
.putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long)
.putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args.get(1) as String)
.apply()
ContentObserverWorker.enable(ctx, immediate = args.get(2) as Boolean)
result.success(true) result.success(true)
} }
"start" -> { "configure" -> {
val args = call.arguments<ArrayList<*>>()!! val args = call.arguments<ArrayList<*>>()!!
val immediate = args.get(0) as Boolean val requireUnmeteredNetwork = args.get(0) as Boolean
val keepExisting = args.get(1) as Boolean val requireCharging = args.get(1) as Boolean
val requireUnmeteredNetwork = args.get(2) as Boolean ContentObserverWorker.configureWork(ctx, requireUnmeteredNetwork, requireCharging)
val requireCharging = args.get(3) as Boolean
val notificationTitle = args.get(4) as String
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit().putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, notificationTitle).apply()
BackupWorker.startWork(ctx, immediate, keepExisting, requireUnmeteredNetwork, requireCharging)
result.success(true) result.success(true)
} }
"stop" -> { "disable" -> {
ContentObserverWorker.disable(ctx)
BackupWorker.stopWork(ctx) BackupWorker.stopWork(ctx)
result.success(true) result.success(true)
} }
"isEnabled" -> { "isEnabled" -> {
result.success(BackupWorker.isEnabled(ctx)) result.success(ContentObserverWorker.isEnabled(ctx))
} }
"isIgnoringBatteryOptimizations" -> { "isIgnoringBatteryOptimizations" -> {
result.success(BackupWorker.isIgnoringBatteryOptimizations(ctx)) result.success(BackupWorker.isIgnoringBatteryOptimizations(ctx))

View File

@@ -8,17 +8,12 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.PowerManager import android.os.PowerManager
import android.os.SystemClock import android.os.SystemClock
import android.provider.MediaStore
import android.provider.BaseColumns
import android.provider.MediaStore.MediaColumns
import android.provider.MediaStore.Images.Media
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.concurrent.futures.ResolvableFuture import androidx.concurrent.futures.ResolvableFuture
import androidx.work.BackoffPolicy import androidx.work.BackoffPolicy
import androidx.work.Constraints import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ForegroundInfo import androidx.work.ForegroundInfo
import androidx.work.ListenableWorker import androidx.work.ListenableWorker
import androidx.work.NetworkType import androidx.work.NetworkType
@@ -26,6 +21,7 @@ import androidx.work.WorkerParameters
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkInfo
import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.ListenableFuture
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor import io.flutter.embedding.engine.dart.DartExecutor
@@ -41,14 +37,7 @@ import java.util.concurrent.TimeUnit
* Starts the Dart runtime/engine and calls `_nativeEntry` function in * Starts the Dart runtime/engine and calls `_nativeEntry` function in
* `background.service.dart` to run the actual backup logic. * `background.service.dart` to run the actual backup logic.
* Called by Android WorkManager when all constraints for the work are met, * Called by Android WorkManager when all constraints for the work are met,
* i.e. a new photo/video is created on the device AND battery is not low. * i.e. battery is not low and optionally Wifi and charging are active.
* Optionally, unmetered network (wifi) and charging can be required.
* As this work is not triggered periodically, but on content change, the
* worker enqueues itself again with the same settings.
* In case the worker is stopped by the system (e.g. constraints like wifi
* are no longer met, or the system needs memory resources for more other
* more important work), the worker is replaced without the constraint on
* changed contents to run again as soon as deemed possible by the system.
*/ */
class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ctx, params), MethodChannel.MethodCallHandler { class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ctx, params), MethodChannel.MethodCallHandler {
@@ -57,14 +46,13 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
private lateinit var backgroundChannel: MethodChannel private lateinit var backgroundChannel: MethodChannel
private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext) private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext)
private var timeBackupStarted: Long = 0L
override fun startWork(): ListenableFuture<ListenableWorker.Result> { override fun startWork(): ListenableFuture<ListenableWorker.Result> {
Log.d(TAG, "startWork")
val ctx = applicationContext val ctx = applicationContext
// enqueue itself once again to continue to listen on added photos/videos
enqueueMoreWork(ctx,
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false))
if (!flutterLoader.initialized()) { if (!flutterLoader.initialized()) {
flutterLoader.startInitialization(ctx) flutterLoader.startInitialization(ctx)
@@ -73,14 +61,16 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
// Create a Notification channel if necessary // Create a Notification channel if necessary
createChannel() createChannel()
} }
val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
if (isIgnoringBatteryOptimizations) { if (isIgnoringBatteryOptimizations) {
// normal background services can only up to 10 minutes // normal background services can only up to 10 minutes
// foreground services are allowed to run indefinitely // foreground services are allowed to run indefinitely
// requires battery optimizations to be disabled (either manually by the user // requires battery optimizations to be disabled (either manually by the user
// or by the system learning that immich is important to the user) // or by the system learning that immich is important to the user)
val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
setForegroundAsync(createForegroundInfo(title)) setForegroundAsync(createForegroundInfo(title))
} else {
showBackgroundInfo(title)
} }
engine = FlutterEngine(ctx) engine = FlutterEngine(ctx)
@@ -115,6 +105,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
} }
override fun onStopped() { override fun onStopped() {
Log.d(TAG, "onStopped")
// called when the system has to stop this worker because constraints are // called when the system has to stop this worker because constraints are
// no longer met or the system needs resources for more important tasks // no longer met or the system needs resources for more important tasks
Handler(Looper.getMainLooper()).postAtFrontOfQueue { Handler(Looper.getMainLooper()).postAtFrontOfQueue {
@@ -130,24 +121,18 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
private fun stopEngine(result: Result?) { private fun stopEngine(result: Result?) {
if (result != null) { if (result != null) {
Log.d(TAG, "stopEngine result=${result}")
resolvableFuture.set(result) resolvableFuture.set(result)
} else if (engine != null && inputData.getInt(DATA_KEY_RETRIES, 0) == 0) {
// stopped by system and this is the first time (content change constraints active)
// replace the task without the content constraints to finish the backup as soon as possible
enqueueMoreWork(applicationContext,
immediate = true,
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false),
initialDelayInMs = ONE_MINUTE,
retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1)
} }
engine?.destroy() engine?.destroy()
engine = null engine = null
clearBackgroundNotification()
} }
override fun onMethodCall(call: MethodCall, r: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, r: MethodChannel.Result) {
when (call.method) { when (call.method) {
"initialized" -> "initialized" -> {
timeBackupStarted = SystemClock.uptimeMillis()
backgroundChannel.invokeMethod( backgroundChannel.invokeMethod(
"onAssetsChanged", "onAssetsChanged",
null, null,
@@ -163,25 +148,18 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
override fun success(receivedResult: Any?) { override fun success(receivedResult: Any?) {
val success = receivedResult as Boolean val success = receivedResult as Boolean
stopEngine(if(success) Result.success() else Result.retry()) stopEngine(if(success) Result.success() else Result.retry())
if (!success && inputData.getInt(DATA_KEY_RETRIES, 0) == 0) {
// there was an error (e.g. server not available)
// replace the task without the content constraints to finish the backup as soon as possible
enqueueMoreWork(applicationContext,
immediate = true,
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false),
initialDelayInMs = ONE_MINUTE,
retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1)
}
} }
} }
) )
}
"updateNotification" -> { "updateNotification" -> {
val args = call.arguments<ArrayList<*>>()!! val args = call.arguments<ArrayList<*>>()!!
val title = args.get(0) as String val title = args.get(0) as String
val content = args.get(1) as String val content = args.get(1) as String
if (isIgnoringBatteryOptimizations) { if (isIgnoringBatteryOptimizations) {
setForegroundAsync(createForegroundInfo(title, content)) setForegroundAsync(createForegroundInfo(title, content))
} else {
showBackgroundInfo(title, content)
} }
} }
"showError" -> { "showError" -> {
@@ -192,6 +170,14 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
showError(title, content, individualTag) showError(title, content, individualTag)
} }
"clearErrorNotifications" -> clearErrorNotifications() "clearErrorNotifications" -> clearErrorNotifications()
"hasContentChanged" -> {
val lastChange = applicationContext
.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getLong(SHARED_PREF_LAST_CHANGE, timeBackupStarted)
val hasContentChanged = lastChange > timeBackupStarted;
timeBackupStarted = SystemClock.uptimeMillis()
r.success(hasContentChanged)
}
else -> r.notImplemented() else -> r.notImplemented()
} }
} }
@@ -211,6 +197,22 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
notificationManager.cancel(NOTIFICATION_ERROR_ID) notificationManager.cancel(NOTIFICATION_ERROR_ID)
} }
private fun showBackgroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null) {
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
.setContentTitle(title)
.setTicker(title)
.setContentText(content)
.setSmallIcon(R.mipmap.ic_launcher)
.setOnlyAlertOnce(true)
.setOngoing(true)
.build()
notificationManager.notify(NOTIFICATION_ID, notification)
}
private fun clearBackgroundNotification() {
notificationManager.cancel(NOTIFICATION_ID)
}
private fun createForegroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null): ForegroundInfo { private fun createForegroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null): ForegroundInfo {
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID) val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
.setContentTitle(title) .setContentTitle(title)
@@ -233,89 +235,61 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
companion object { companion object {
const val SHARED_PREF_NAME = "immichBackgroundService" const val SHARED_PREF_NAME = "immichBackgroundService"
const val SHARED_PREF_CALLBACK_KEY = "callbackDispatcherHandle" const val SHARED_PREF_CALLBACK_KEY = "callbackDispatcherHandle"
const val SHARED_PREF_SERVICE_ENABLED = "serviceEnabled"
const val SHARED_PREF_NOTIFICATION_TITLE = "notificationTitle" const val SHARED_PREF_NOTIFICATION_TITLE = "notificationTitle"
const val SHARED_PREF_LAST_CHANGE = "lastChange"
private const val TASK_NAME = "immich/photoListener" private const val TASK_NAME_BACKUP = "immich/BackupWorker"
private const val DATA_KEY_UNMETERED = "unmetered"
private const val DATA_KEY_CHARGING = "charging"
private const val DATA_KEY_RETRIES = "retries"
private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService" private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService"
private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError" private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError"
private const val NOTIFICATION_DEFAULT_TITLE = "Immich" private const val NOTIFICATION_DEFAULT_TITLE = "Immich"
private const val NOTIFICATION_ID = 1 private const val NOTIFICATION_ID = 1
private const val NOTIFICATION_ERROR_ID = 2 private const val NOTIFICATION_ERROR_ID = 2
private const val ONE_MINUTE: Long = 60000 private const val ONE_MINUTE = 60000L
/** /**
* Enqueues the `BackupWorker` to run when all constraints are met. * Enqueues the BackupWorker to run once the constraints are met
*
* @param context Android Context
* @param immediate whether to enqueue(replace) the worker without the content change constraint
* @param keepExisting if true, use `ExistingWorkPolicy.KEEP`, else `ExistingWorkPolicy.APPEND_OR_REPLACE`
* @param requireUnmeteredNetwork if true, task only runs if connected to wifi
* @param requireCharging if true, task only runs if device is charging
* @param retries retry count (should be 0 unless an error occured and this is a retry)
*/ */
fun startWork(context: Context, fun enqueueBackupWorker(context: Context,
immediate: Boolean = false, requireWifi: Boolean = false,
keepExisting: Boolean = false, requireCharging: Boolean = false,
requireUnmeteredNetwork: Boolean = false, delayMilliseconds: Long = 0L) {
requireCharging: Boolean = false) { val workRequest = buildWorkRequest(requireWifi, requireCharging, delayMilliseconds)
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.KEEP, workRequest)
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, true).apply() Log.d(TAG, "enqueueBackupWorker: BackupWorker enqueued")
enqueueMoreWork(context, immediate, keepExisting, requireUnmeteredNetwork, requireCharging)
} }
private fun enqueueMoreWork(context: Context, /**
immediate: Boolean = false, * Updates the constraints of an already enqueued BackupWorker
keepExisting: Boolean = false, */
requireUnmeteredNetwork: Boolean = false, fun updateBackupWorker(context: Context,
requireCharging: Boolean = false, requireWifi: Boolean = false,
initialDelayInMs: Long = 0, requireCharging: Boolean = false) {
retries: Int = 0) { try {
if (!isEnabled(context)) { val wm = WorkManager.getInstance(context)
return val workInfoFuture = wm.getWorkInfosForUniqueWork(TASK_NAME_BACKUP)
val workInfoList = workInfoFuture.get(1000, TimeUnit.MILLISECONDS)
if (workInfoList != null) {
for (workInfo in workInfoList) {
if (workInfo.getState() == WorkInfo.State.ENQUEUED) {
val workRequest = buildWorkRequest(requireWifi, requireCharging)
wm.enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.REPLACE, workRequest)
Log.d(TAG, "updateBackupWorker updated BackupWorker constraints")
return
}
}
}
Log.d(TAG, "updateBackupWorker: BackupWorker not enqueued")
} catch (e: Exception) {
Log.d(TAG, "updateBackupWorker failed: ${e}")
} }
val constraints = Constraints.Builder()
.setRequiredNetworkType(if (requireUnmeteredNetwork) NetworkType.UNMETERED else NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.setRequiresCharging(requireCharging);
if (!immediate) {
constraints
.addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
}
val inputData = Data.Builder()
.putBoolean(DATA_KEY_CHARGING, requireCharging)
.putBoolean(DATA_KEY_UNMETERED, requireUnmeteredNetwork)
.putInt(DATA_KEY_RETRIES, retries)
.build()
val photoCheck = OneTimeWorkRequest.Builder(BackupWorker::class.java)
.setConstraints(constraints.build())
.setInputData(inputData)
.setInitialDelay(initialDelayInMs, TimeUnit.MILLISECONDS)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
ONE_MINUTE,
TimeUnit.MILLISECONDS)
.build()
val policy = if (immediate) ExistingWorkPolicy.REPLACE else (if (keepExisting) ExistingWorkPolicy.KEEP else ExistingWorkPolicy.APPEND_OR_REPLACE)
val op = WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME, policy, photoCheck)
val result = op.getResult().get()
} }
/** /**
* Stops the currently running worker (if any) and removes it from the work queue * Stops the currently running worker (if any) and removes it from the work queue
*/ */
fun stopWork(context: Context) { fun stopWork(context: Context) {
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_BACKUP)
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, false).apply() Log.d(TAG, "stopWork: BackupWorker cancelled")
WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME)
} }
/** /**
@@ -330,12 +304,21 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
return true return true
} }
/** private fun buildWorkRequest(requireWifi: Boolean = false,
* Return true if the user has enabled the background backup service requireCharging: Boolean = false,
*/ delayMilliseconds: Long = 0L): OneTimeWorkRequest {
fun isEnabled(ctx: Context): Boolean { val constraints = Constraints.Builder()
return ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) .setRequiredNetworkType(if (requireWifi) NetworkType.UNMETERED else NetworkType.CONNECTED)
.getBoolean(SHARED_PREF_SERVICE_ENABLED, false) .setRequiresBatteryNotLow(true)
.setRequiresCharging(requireCharging)
.build();
val work = OneTimeWorkRequest.Builder(BackupWorker::class.java)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, ONE_MINUTE, TimeUnit.MILLISECONDS)
.setInitialDelay(delayMilliseconds, TimeUnit.MILLISECONDS)
.build()
return work
} }
private val flutterLoader = FlutterLoader() private val flutterLoader = FlutterLoader()

View File

@@ -0,0 +1,137 @@
package app.alextran.immich
import android.content.Context
import android.os.SystemClock
import android.provider.MediaStore
import android.util.Log
import androidx.work.Constraints
import androidx.work.Worker
import androidx.work.WorkerParameters
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.Operation
import java.util.concurrent.TimeUnit
/**
* Worker executed by Android WorkManager observing content changes (new photos/videos)
*
* Immediately enqueues the BackupWorker when running.
* As this work is not triggered periodically, but on content change, the
* worker enqueues itself again after each run.
*/
class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, params) {
override fun doWork(): Result {
if (!isEnabled(applicationContext)) {
return Result.failure()
}
if (getTriggeredContentUris().size > 0) {
startBackupWorker(applicationContext, delayMilliseconds = 0)
}
enqueueObserverWorker(applicationContext, ExistingWorkPolicy.REPLACE)
return Result.success()
}
companion object {
const val SHARED_PREF_SERVICE_ENABLED = "serviceEnabled"
const val SHARED_PREF_REQUIRE_WIFI = "requireWifi"
const val SHARED_PREF_REQUIRE_CHARGING = "requireCharging"
private const val TASK_NAME_OBSERVER = "immich/ContentObserver"
/**
* Enqueues the `ContentObserverWorker`.
*
* @param context Android Context
*/
fun enable(context: Context, immediate: Boolean = false) {
// migration to remove any old active background task
WorkManager.getInstance(context).cancelUniqueWork("immich/photoListener")
enqueueObserverWorker(context, ExistingWorkPolicy.KEEP)
Log.d(TAG, "enabled ContentObserverWorker")
if (immediate) {
startBackupWorker(context, delayMilliseconds = 5000)
}
}
/**
* Configures the `BackupWorker` to run when all constraints are met.
*
* @param context Android Context
* @param requireWifi if true, task only runs if connected to wifi
* @param requireCharging if true, task only runs if device is charging
*/
fun configureWork(context: Context,
requireWifi: Boolean = false,
requireCharging: Boolean = false) {
context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit()
.putBoolean(SHARED_PREF_SERVICE_ENABLED, true)
.putBoolean(SHARED_PREF_REQUIRE_WIFI, requireWifi)
.putBoolean(SHARED_PREF_REQUIRE_CHARGING, requireCharging)
.apply()
BackupWorker.updateBackupWorker(context, requireWifi, requireCharging)
}
/**
* Stops the currently running worker (if any) and removes it from the work queue
*/
fun disable(context: Context) {
context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, false).apply()
WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_OBSERVER)
Log.d(TAG, "disabled ContentObserverWorker")
}
/**
* Return true if the user has enabled the background backup service
*/
fun isEnabled(ctx: Context): Boolean {
return ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getBoolean(SHARED_PREF_SERVICE_ENABLED, false)
}
/**
* Enqueue and replace the worker without the content trigger but with a short delay
*/
fun workManagerAppClearedWorkaround(context: Context) {
val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java)
.setInitialDelay(500, TimeUnit.MILLISECONDS)
.build()
WorkManager
.getInstance(context)
.enqueueUniqueWork(TASK_NAME_OBSERVER, ExistingWorkPolicy.REPLACE, work)
.getResult()
.get()
Log.d(TAG, "workManagerAppClearedWorkaround")
}
private fun enqueueObserverWorker(context: Context, policy: ExistingWorkPolicy) {
val constraints = Constraints.Builder()
.addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
.setTriggerContentUpdateDelay(5000, TimeUnit.MILLISECONDS)
.build()
val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_OBSERVER, policy, work)
}
private fun startBackupWorker(context: Context, delayMilliseconds: Long) {
val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
val requireWifi = sp.getBoolean(SHARED_PREF_REQUIRE_WIFI, true)
val requireCharging = sp.getBoolean(SHARED_PREF_REQUIRE_CHARGING, false)
BackupWorker.enqueueBackupWorker(context, requireWifi, requireCharging, delayMilliseconds)
sp.edit().putLong(BackupWorker.SHARED_PREF_LAST_CHANGE, SystemClock.uptimeMillis()).apply()
}
}
}
private const val TAG = "ContentObserverWorker"

View File

@@ -2,6 +2,8 @@ package app.alextran.immich
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import android.os.Bundle
import android.content.Intent
class MainActivity: FlutterActivity() { class MainActivity: FlutterActivity() {
@@ -10,4 +12,14 @@ class MainActivity: FlutterActivity() {
flutterEngine.getPlugins().add(BackgroundServicePlugin()) flutterEngine.getPlugins().add(BackgroundServicePlugin())
} }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
try {
startService(Intent(getBaseContext(), AppClearedService::class.java));
} catch (e: Exception) {
// startService must not be called when app is in background (crashes app)
// there is nothing we can do
}
}
} }

View File

@@ -16,12 +16,17 @@
default_platform(:android) default_platform(:android)
platform :android do platform :android do
desc "Build Android" desc "Build Android and Release Testing"
lane :build do lane :beta do
gradle( gradle(
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: {
"android.injected.version.code" => 47,
"android.injected.version.name" => "1.30.2",
}
) )
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab', track: 'beta')
end end
desc "Build and Release Android" desc "Build and Release Android"
@@ -30,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 38, "android.injected.version.code" => 48,
"android.injected.version.name" => "1.28.0", "android.injected.version.name" => "1.30.2",
} }
) )
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -15,13 +15,13 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do
## Android ## Android
### android build ### android beta
```sh ```sh
[bundle exec] fastlane android build [bundle exec] fastlane android beta
``` ```
Build Android Build Android and Release Testing
### android release ### android release

View File

@@ -0,0 +1 @@
* Improve Android background service reliability

View File

@@ -0,0 +1 @@
* Fix background service cannot run in release build

View File

@@ -0,0 +1,2 @@
* Fixed oversize play button on video
* Fixed app crashing when swipe between assets

View File

@@ -0,0 +1,2 @@
* Fixed Android BackgroundServiceStartNotAllowedException
* Restore old cache mechanism

View File

@@ -0,0 +1 @@
* Update deprecated API that causes notification not dismissing after background upload progress finished.

View File

@@ -0,0 +1 @@
* Fixed app crashes when there is no object detection result.

View File

@@ -0,0 +1 @@
* Correctly display time based on timezone

View File

@@ -0,0 +1 @@
* Added improvement for timeline view

View File

@@ -0,0 +1 @@
* Improve scroll thumb date info

View File

@@ -0,0 +1 @@
* Fixed parsing date error prevent timeline to be loaded.

View File

@@ -5,17 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000222"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000233">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="55.311329"> <testcase classname="fastlane.lanes" name="1: bundleRelease" time="61.699536">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="30.070842"> <testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="46.210553">
</testcase> </testcase>

View File

@@ -165,5 +165,10 @@
"version_announcement_overlay_text_1": "Hi friend, there is a new release of", "version_announcement_overlay_text_1": "Hi friend, there is a new release of",
"version_announcement_overlay_text_2": "please take your time to visit the ", "version_announcement_overlay_text_2": "please take your time to visit the ",
"version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89" "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
"experimental_settings_title": "Experimental",
"experimental_settings_subtitle": "Use at your own risk!",
"experimental_settings_new_asset_list_title": "Enable experimental photo grid",
"experimental_settings_new_asset_list_subtitle": "Work in progress",
"settings_require_restart": "Please restart Immich to apply this setting"
} }

View File

@@ -360,11 +360,11 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 52; CURRENT_PROJECT_VERSION = 62;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 11.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -495,11 +495,11 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 52; CURRENT_PROJECT_VERSION = 62;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 11.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -522,11 +522,11 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 52; CURRENT_PROJECT_VERSION = 62;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 11.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",

View File

@@ -17,11 +17,11 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.27.0</string> <string>1.30.1</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>52</string> <string>62</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true /> <true />
<key>MGLMapboxMetricsEnabledSettingShownInApp</key> <key>MGLMapboxMetricsEnabledSettingShownInApp</key>

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta" desc "iOS Beta"
lane :beta do lane :beta do
increment_version_number( increment_version_number(
version_number: "1.28.0" version_number: "1.30.2"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,

View File

@@ -5,32 +5,32 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000269"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000209">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.499192"> <testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.78333">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="30.057077"> <testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="3.947588">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.438506"> <testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.505399">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="91.259106"> <testcase classname="fastlane.lanes" name="4: build_app" time="80.954627">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="102.092139"> <testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="58.295965">
</testcase> </testcase>

View File

@@ -7,7 +7,6 @@ import 'package:flutter/material.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/services/cache.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -15,11 +14,9 @@ class AlbumThumbnailCard extends StatelessWidget {
const AlbumThumbnailCard({ const AlbumThumbnailCard({
Key? key, Key? key,
required this.album, required this.album,
required this.cacheService,
}) : super(key: key); }) : super(key: key);
final AlbumResponseDto album; final AlbumResponseDto album;
final CacheService cacheService;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -39,7 +36,6 @@ class AlbumThumbnailCard extends StatelessWidget {
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage( child: CachedNetworkImage(
cacheManager: cacheService.getCache(CacheType.albumThumbnail),
memCacheHeight: max(400, cardSize.toInt() * 3), memCacheHeight: max(400, cardSize.toInt() * 3),
width: cardSize, width: cardSize,
height: cardSize, height: cardSize,

View File

@@ -1,15 +1,12 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart'; import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/services/cache.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -17,13 +14,11 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
final AssetResponseDto asset; final AssetResponseDto asset;
final List<AssetResponseDto> assetList; final List<AssetResponseDto> assetList;
final bool showStorageIndicator; final bool showStorageIndicator;
final BaseCacheManager? cacheManager;
const AlbumViewerThumbnail({ const AlbumViewerThumbnail({
Key? key, Key? key,
required this.asset, required this.asset,
required this.assetList, required this.assetList,
this.cacheManager,
this.showStorageIndicator = true, this.showStorageIndicator = true,
}) : super(key: key); }) : super(key: key);
@@ -126,7 +121,6 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
return Container( return Container(
decoration: BoxDecoration(border: drawBorderColor()), decoration: BoxDecoration(border: drawBorderColor()),
child: CachedNetworkImage( child: CachedNetworkImage(
cacheManager: cacheManager,
cacheKey: asset.id, cacheKey: asset.id,
width: 300, width: 300,
height: 300, height: 300,

View File

@@ -1,11 +1,9 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart'; import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
import 'package:immich_mobile/shared/services/cache.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -24,7 +22,6 @@ class SelectionThumbnailImage extends HookConsumerWidget {
var newAssetsForAlbum = var newAssetsForAlbum =
ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum; ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist; var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
final cacheService = ref.watch(cacheServiceProvider);
Widget _buildSelectionIcon(AssetResponseDto asset) { Widget _buildSelectionIcon(AssetResponseDto asset) {
var isSelected = selectedAsset.map((item) => item.id).contains(asset.id); var isSelected = selectedAsset.map((item) => item.id).contains(asset.id);
@@ -114,7 +111,6 @@ class SelectionThumbnailImage extends HookConsumerWidget {
Container( Container(
decoration: BoxDecoration(border: drawBorderColor()), decoration: BoxDecoration(border: drawBorderColor()),
child: CachedNetworkImage( child: CachedNetworkImage(
cacheManager: cacheService.getCache(CacheType.thumbnail),
cacheKey: asset.id, cacheKey: asset.id,
width: 150, width: 150,
height: 150, height: 150,

View File

@@ -1,10 +1,8 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/services/cache.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -16,7 +14,6 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final cacheService = ref.watch(cacheServiceProvider);
var box = Hive.box(userInfoBox); var box = Hive.box(userInfoBox);
return GestureDetector( return GestureDetector(
@@ -26,7 +23,6 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
child: Stack( child: Stack(
children: [ children: [
CachedNetworkImage( CachedNetworkImage(
cacheManager: cacheService.getCache(CacheType.thumbnail),
cacheKey: asset.id, cacheKey: asset.id,
width: 500, width: 500,
height: 500, height: 500,

View File

@@ -16,7 +16,6 @@ import 'package:immich_mobile/modules/album/ui/album_viewer_thumbnail.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/services/cache.service.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart'; import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
@@ -192,7 +191,6 @@ class AlbumViewerPage extends HookConsumerWidget {
final appSettingService = ref.watch(appSettingsServiceProvider); final appSettingService = ref.watch(appSettingsServiceProvider);
final bool showStorageIndicator = final bool showStorageIndicator =
appSettingService.getSetting(AppSettingsEnum.storageIndicator); appSettingService.getSetting(AppSettingsEnum.storageIndicator);
final cacheService = ref.watch(cacheServiceProvider);
if (albumInfo.assets.isNotEmpty) { if (albumInfo.assets.isNotEmpty) {
return SliverPadding( return SliverPadding(
@@ -207,7 +205,6 @@ class AlbumViewerPage extends HookConsumerWidget {
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) { (BuildContext context, int index) {
return AlbumViewerThumbnail( return AlbumViewerThumbnail(
cacheManager: cacheService.getCache(CacheType.thumbnail),
asset: albumInfo.assets[index], asset: albumInfo.assets[index],
assetList: albumInfo.assets, assetList: albumInfo.assets,
showStorageIndicator: showStorageIndicator, showStorageIndicator: showStorageIndicator,

View File

@@ -6,7 +6,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart'; import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/services/cache.service.dart';
class LibraryPage extends HookConsumerWidget { class LibraryPage extends HookConsumerWidget {
const LibraryPage({Key? key}) : super(key: key); const LibraryPage({Key? key}) : super(key: key);
@@ -14,7 +13,6 @@ class LibraryPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final albums = ref.watch(albumProvider); final albums = ref.watch(albumProvider);
final cacheService = ref.watch(cacheServiceProvider);
useEffect( useEffect(
() { () {
@@ -104,7 +102,6 @@ class LibraryPage extends HookConsumerWidget {
_buildCreateAlbumButton(), _buildCreateAlbumButton(),
for (var album in albums) for (var album in albums)
AlbumThumbnailCard( AlbumThumbnailCard(
cacheService: cacheService,
album: album, album: album,
), ),
], ],

View File

@@ -9,7 +9,6 @@ import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/ui/sharing_sliver_appbar.dart'; import 'package:immich_mobile/modules/album/ui/sharing_sliver_appbar.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/services/cache.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -21,7 +20,6 @@ class SharingPage extends HookConsumerWidget {
var box = Hive.box(userInfoBox); var box = Hive.box(userInfoBox);
var thumbnailRequestUrl = '${box.get(serverEndpointKey)}/asset/thumbnail'; var thumbnailRequestUrl = '${box.get(serverEndpointKey)}/asset/thumbnail';
final List<AlbumResponseDto> sharedAlbums = ref.watch(sharedAlbumProvider); final List<AlbumResponseDto> sharedAlbums = ref.watch(sharedAlbumProvider);
final CacheService cacheService = ref.watch(cacheServiceProvider);
useEffect( useEffect(
() { () {
@@ -47,8 +45,6 @@ class SharingPage extends HookConsumerWidget {
height: 60, height: 60,
memCacheHeight: 200, memCacheHeight: 200,
fit: BoxFit.cover, fit: BoxFit.cover,
cacheManager:
cacheService.getCache(CacheType.sharedAlbumThumbnail),
imageUrl: getAlbumThumbnailUrl(album), imageUrl: getAlbumThumbnailUrl(album),
cacheKey: album.albumThumbnailAssetId, cacheKey: album.albumThumbnailAssetId,
httpHeaders: { httpHeaders: {

View File

@@ -81,7 +81,7 @@ class ExifBottomSheet extends ConsumerWidget {
if (assetDetail.exifInfo?.dateTimeOriginal != null) if (assetDetail.exifInfo?.dateTimeOriginal != null)
Text( Text(
DateFormat('date_format'.tr()).format( DateFormat('date_format'.tr()).format(
assetDetail.exifInfo!.dateTimeOriginal!, assetDetail.exifInfo!.dateTimeOriginal!.toLocal(),
), ),
style: TextStyle( style: TextStyle(
color: Colors.grey[400], color: Colors.grey[400],

View File

@@ -1,7 +1,5 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view.dart';
enum _RemoteImageStatus { empty, thumbnail, preview, full } enum _RemoteImageStatus { empty, thumbnail, preview, full }
@@ -12,6 +10,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
bool _zoomedIn = false; bool _zoomedIn = false;
static const int swipeThreshold = 100; static const int swipeThreshold = 100;
late CachedNetworkImageProvider fullProvider;
late CachedNetworkImageProvider previewProvider;
late CachedNetworkImageProvider thumbnailProvider;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -56,21 +57,14 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
widget.isZoomedFunction(); widget.isZoomedFunction();
} }
void _fireStartLoadingEvent() {
widget.onLoadingStart();
}
void _fireFinishedLoadingEvent() {
widget.onLoadingCompleted();
}
CachedNetworkImageProvider _authorizedImageProvider( CachedNetworkImageProvider _authorizedImageProvider(
String url, String cacheKey, BaseCacheManager? cacheManager) { String url,
String cacheKey,
) {
return CachedNetworkImageProvider( return CachedNetworkImageProvider(
url, url,
headers: {"Authorization": widget.authToken}, headers: {"Authorization": widget.authToken},
cacheKey: cacheKey, cacheKey: cacheKey,
cacheManager: cacheManager,
); );
} }
@@ -91,12 +85,6 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
if (!mounted) return; if (!mounted) return;
if (newStatus != _RemoteImageStatus.full) {
_fireStartLoadingEvent();
} else {
_fireFinishedLoadingEvent();
}
setState(() { setState(() {
_status = newStatus; _status = newStatus;
_imageProvider = provider; _imageProvider = provider;
@@ -104,10 +92,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
} }
void _loadImages() { void _loadImages() {
CachedNetworkImageProvider thumbnailProvider = _authorizedImageProvider( thumbnailProvider = _authorizedImageProvider(
widget.thumbnailUrl, widget.thumbnailUrl,
widget.cacheKey, widget.cacheKey,
widget.thumbnailCacheManager,
); );
_imageProvider = thumbnailProvider; _imageProvider = thumbnailProvider;
@@ -121,10 +108,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
); );
if (widget.previewUrl != null) { if (widget.previewUrl != null) {
CachedNetworkImageProvider previewProvider = _authorizedImageProvider( previewProvider = _authorizedImageProvider(
widget.previewUrl!, widget.previewUrl!,
"${widget.cacheKey}_previewStage", "${widget.cacheKey}_previewStage",
widget.previewCacheManager,
); );
previewProvider.resolve(const ImageConfiguration()).addListener( previewProvider.resolve(const ImageConfiguration()).addListener(
ImageStreamListener((ImageInfo imageInfo, _) { ImageStreamListener((ImageInfo imageInfo, _) {
@@ -133,10 +119,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
); );
} }
CachedNetworkImageProvider fullProvider = _authorizedImageProvider( fullProvider = _authorizedImageProvider(
widget.imageUrl, widget.imageUrl,
"${widget.cacheKey}_fullStage", "${widget.cacheKey}_fullStage",
widget.fullCacheManager,
); );
fullProvider.resolve(const ImageConfiguration()).addListener( fullProvider.resolve(const ImageConfiguration()).addListener(
ImageStreamListener((ImageInfo imageInfo, _) { ImageStreamListener((ImageInfo imageInfo, _) {
@@ -147,8 +132,23 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
@override @override
void initState() { void initState() {
_loadImages();
super.initState(); super.initState();
_loadImages();
}
@override
void dispose() async {
super.dispose();
if (_status == _RemoteImageStatus.full) {
await fullProvider.evict();
} else if (_status == _RemoteImageStatus.preview) {
await previewProvider.evict();
} else if (_status == _RemoteImageStatus.thumbnail) {
await thumbnailProvider.evict();
}
await _imageProvider.evict();
} }
} }
@@ -163,11 +163,6 @@ class RemotePhotoView extends StatefulWidget {
required this.onSwipeDown, required this.onSwipeDown,
required this.onSwipeUp, required this.onSwipeUp,
this.previewUrl, this.previewUrl,
required this.onLoadingCompleted,
required this.onLoadingStart,
this.thumbnailCacheManager,
this.previewCacheManager,
this.fullCacheManager,
required this.cacheKey, required this.cacheKey,
}) : super(key: key); }) : super(key: key);
@@ -175,11 +170,6 @@ class RemotePhotoView extends StatefulWidget {
final String imageUrl; final String imageUrl;
final String authToken; final String authToken;
final String? previewUrl; final String? previewUrl;
final Function onLoadingCompleted;
final Function onLoadingStart;
final BaseCacheManager? thumbnailCacheManager;
final BaseCacheManager? previewCacheManager;
final BaseCacheManager? fullCacheManager;
final String cacheKey; final String cacheKey;
final void Function() onSwipeDown; final void Function() onSwipeDown;

View File

@@ -24,11 +24,9 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
double iconSize = 18.0; double iconSize = 18.0;
return AppBar( return AppBar(
// iconTheme: IconThemeData(color: Colors.grey[100]),
// actionsIconTheme: IconThemeData(color: Colors.grey[100]),
foregroundColor: Colors.grey[100], foregroundColor: Colors.grey[100],
toolbarHeight: 60, toolbarHeight: 60,
backgroundColor: Colors.black, backgroundColor: Colors.transparent,
leading: IconButton( leading: IconButton(
onPressed: () { onPressed: () {
AutoRouter.of(context).pop(); AutoRouter.of(context).pop();

View File

@@ -121,8 +121,6 @@ class GalleryViewerPage extends HookConsumerWidget {
authToken: 'Bearer ${box.get(accessTokenKey)}', authToken: 'Bearer ${box.get(accessTokenKey)}',
isZoomedFunction: isZoomedMethod, isZoomedFunction: isZoomedMethod,
isZoomedListener: isZoomedListener, isZoomedListener: isZoomedListener,
onLoadingCompleted: () => {},
onLoadingStart: () => {},
asset: assetList[index], asset: assetList[index],
heroTag: assetList[index].id, heroTag: assetList[index].id,
threeStageLoading: threeStageLoading.value, threeStageLoading: threeStageLoading.value,

View File

@@ -8,7 +8,6 @@ import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart'; import 'package:immich_mobile/modules/home/services/asset.service.dart';
import 'package:immich_mobile/shared/services/cache.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -19,8 +18,6 @@ class ImageViewerPage extends HookConsumerWidget {
final String authToken; final String authToken;
final ValueNotifier<bool> isZoomedListener; final ValueNotifier<bool> isZoomedListener;
final void Function() isZoomedFunction; final void Function() isZoomedFunction;
final void Function() onLoadingCompleted;
final void Function() onLoadingStart;
final bool threeStageLoading; final bool threeStageLoading;
ImageViewerPage({ ImageViewerPage({
@@ -30,8 +27,6 @@ class ImageViewerPage extends HookConsumerWidget {
required this.authToken, required this.authToken,
required this.isZoomedFunction, required this.isZoomedFunction,
required this.isZoomedListener, required this.isZoomedListener,
required this.onLoadingCompleted,
required this.onLoadingStart,
required this.threeStageLoading, required this.threeStageLoading,
}) : super(key: key); }) : super(key: key);
@@ -41,7 +36,6 @@ class ImageViewerPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final downloadAssetStatus = final downloadAssetStatus =
ref.watch(imageViewerStateProvider).downloadAssetStatus; ref.watch(imageViewerStateProvider).downloadAssetStatus;
final cacheService = ref.watch(cacheServiceProvider);
getAssetExif() async { getAssetExif() async {
assetDetail = assetDetail =
@@ -85,14 +79,6 @@ class ImageViewerPage extends HookConsumerWidget {
isZoomedListener: isZoomedListener, isZoomedListener: isZoomedListener,
onSwipeDown: () => AutoRouter.of(context).pop(), onSwipeDown: () => AutoRouter.of(context).pop(),
onSwipeUp: () => showInfo(), onSwipeUp: () => showInfo(),
onLoadingCompleted: onLoadingCompleted,
onLoadingStart: onLoadingStart,
thumbnailCacheManager:
cacheService.getCache(CacheType.thumbnail),
previewCacheManager:
cacheService.getCache(CacheType.imageViewerPreview),
fullCacheManager:
cacheService.getCache(CacheType.imageViewerFull),
), ),
), ),
), ),

View File

@@ -79,7 +79,7 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
_createChewieController() { _createChewieController() {
chewieController = ChewieController( chewieController = ChewieController(
showOptions: true, showOptions: true,
showControlsOnInitialize: true, showControlsOnInitialize: false,
videoPlayerController: videoPlayerController, videoPlayerController: videoPlayerController,
autoPlay: true, autoPlay: true,
autoInitialize: true, autoInitialize: true,

View File

@@ -4,7 +4,6 @@ import 'dart:io';
import 'dart:isolate'; import 'dart:isolate';
import 'dart:ui' show IsolateNameServer, PluginUtilities; import 'dart:ui' show IsolateNameServer, PluginUtilities;
import 'package:cancellation_token_http/http.dart'; import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@@ -33,7 +32,6 @@ class BackgroundService {
MethodChannel('immich/foregroundChannel'); MethodChannel('immich/foregroundChannel');
static const MethodChannel _backgroundChannel = static const MethodChannel _backgroundChannel =
MethodChannel('immich/backgroundChannel'); MethodChannel('immich/backgroundChannel');
bool _isForegroundInitialized = false;
bool _isBackgroundInitialized = false; bool _isBackgroundInitialized = false;
CancellationToken? _cancellationToken; CancellationToken? _cancellationToken;
bool _canceledBySystem = false; bool _canceledBySystem = false;
@@ -43,32 +41,34 @@ class BackgroundService {
ReceivePort? _rp; ReceivePort? _rp;
bool _errorGracePeriodExceeded = true; bool _errorGracePeriodExceeded = true;
bool get isForegroundInitialized {
return _isForegroundInitialized;
}
bool get isBackgroundInitialized { bool get isBackgroundInitialized {
return _isBackgroundInitialized; return _isBackgroundInitialized;
} }
Future<bool> _initialize() async {
final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!;
var result = await _foregroundChannel
.invokeMethod('initialize', [callback.toRawHandle()]);
_isForegroundInitialized = true;
return result;
}
/// Ensures that the background service is enqueued if enabled in settings /// Ensures that the background service is enqueued if enabled in settings
Future<bool> resumeServiceIfEnabled() async { Future<bool> resumeServiceIfEnabled() async {
return await isBackgroundBackupEnabled() && return await isBackgroundBackupEnabled() && await enableService();
await startService(keepExisting: true);
} }
/// Enqueues the background service /// Enqueues the background service
Future<bool> startService({ Future<bool> enableService({bool immediate = false}) async {
bool immediate = false, if (!Platform.isAndroid) {
bool keepExisting = false, return true;
}
try {
final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!;
final String title =
"backup_background_service_default_notification".tr();
final bool ok = await _foregroundChannel
.invokeMethod('enable', [callback.toRawHandle(), title, immediate]);
return ok;
} catch (error) {
return false;
}
}
/// Configures the background service
Future<bool> configureService({
bool requireUnmetered = true, bool requireUnmetered = true,
bool requireCharging = false, bool requireCharging = false,
}) async { }) async {
@@ -76,14 +76,9 @@ class BackgroundService {
return true; return true;
} }
try { try {
if (!_isForegroundInitialized) {
await _initialize();
}
final String title =
"backup_background_service_default_notification".tr();
final bool ok = await _foregroundChannel.invokeMethod( final bool ok = await _foregroundChannel.invokeMethod(
'start', 'configure',
[immediate, keepExisting, requireUnmetered, requireCharging, title], [requireUnmetered, requireCharging],
); );
return ok; return ok;
} catch (error) { } catch (error) {
@@ -92,15 +87,12 @@ class BackgroundService {
} }
/// Cancels the background service (if currently running) and removes it from work queue /// Cancels the background service (if currently running) and removes it from work queue
Future<bool> stopService() async { Future<bool> disableService() async {
if (!Platform.isAndroid) { if (!Platform.isAndroid) {
return true; return true;
} }
try { try {
if (!_isForegroundInitialized) { final ok = await _foregroundChannel.invokeMethod('disable');
await _initialize();
}
final ok = await _foregroundChannel.invokeMethod('stop');
return ok; return ok;
} catch (error) { } catch (error) {
return false; return false;
@@ -113,9 +105,6 @@ class BackgroundService {
return false; return false;
} }
try { try {
if (!_isForegroundInitialized) {
await _initialize();
}
return await _foregroundChannel.invokeMethod("isEnabled"); return await _foregroundChannel.invokeMethod("isEnabled");
} catch (error) { } catch (error) {
return false; return false;
@@ -128,9 +117,6 @@ class BackgroundService {
return true; return true;
} }
try { try {
if (!_isForegroundInitialized) {
await _initialize();
}
return await _foregroundChannel return await _foregroundChannel
.invokeMethod('isIgnoringBatteryOptimizations'); .invokeMethod('isIgnoringBatteryOptimizations');
} catch (error) { } catch (error) {
@@ -187,7 +173,8 @@ class BackgroundService {
} }
} catch (error) { } catch (error) {
debugPrint( debugPrint(
"[_clearErrorNotifications] failed to communicate with plugin"); "[_clearErrorNotifications] failed to communicate with plugin",
);
} }
return false; return false;
} }
@@ -289,18 +276,11 @@ class BackgroundService {
try { try {
final bool hasAccess = await acquireLock(); final bool hasAccess = await acquireLock();
if (!hasAccess) { if (!hasAccess) {
debugPrint("[_callHandler] could acquire lock, exiting"); debugPrint("[_callHandler] could not acquire lock, exiting");
return false; return false;
} }
await translationsLoaded; await translationsLoaded;
final bool ok = await _onAssetsChanged(); final bool ok = await _onAssetsChanged();
if (ok) {
Hive.box(backgroundBackupInfoBox).delete(backupFailedSince);
} else if (Hive.box(backgroundBackupInfoBox).get(backupFailedSince) ==
null) {
Hive.box(backgroundBackupInfoBox)
.put(backupFailedSince, DateTime.now());
}
return ok; return ok;
} catch (error) { } catch (error) {
debugPrint(error.toString()); debugPrint(error.toString());
@@ -343,6 +323,31 @@ class BackgroundService {
} }
await PhotoManager.setIgnorePermissionCheck(true); await PhotoManager.setIgnorePermissionCheck(true);
do {
final bool backupOk = await _runBackup(backupService, backupAlbumInfo);
if (backupOk) {
await Hive.box(backgroundBackupInfoBox).delete(backupFailedSince);
await box.put(
backupInfoKey,
backupAlbumInfo,
);
} else if (Hive.box(backgroundBackupInfoBox).get(backupFailedSince) ==
null) {
Hive.box(backgroundBackupInfoBox)
.put(backupFailedSince, DateTime.now());
return false;
}
// check for new assets added while performing backup
} while (true ==
await _backgroundChannel.invokeMethod<bool>("hasContentChanged"));
return true;
}
Future<bool> _runBackup(
BackupService backupService,
HiveBackupAlbums backupAlbumInfo,
) async {
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded(); _errorGracePeriodExceeded = _isErrorGracePeriodExceeded();
if (_canceledBySystem) { if (_canceledBySystem) {
@@ -382,10 +387,6 @@ class BackgroundService {
); );
if (ok) { if (ok) {
_clearErrorNotifications(); _clearErrorNotifications();
await box.put(
backupInfoKey,
backupAlbumInfo,
);
} else { } else {
_showErrorNotification( _showErrorNotification(
title: "backup_background_service_error_title".tr(), title: "backup_background_service_error_title".tr(),
@@ -447,6 +448,7 @@ class BackgroundService {
} }
/// entry point called by Kotlin/Java code; needs to be a top-level function /// entry point called by Kotlin/Java code; needs to be a top-level function
@pragma('vm:entry-point')
void _nativeEntry() { void _nativeEntry() {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
BackgroundService backgroundService = BackgroundService(); BackgroundService backgroundService = BackgroundService();

View File

@@ -26,7 +26,7 @@ class AvailableAlbum {
String get name => albumEntity.name; String get name => albumEntity.name;
int get assetCount => albumEntity.assetCount; Future<int> get assetCount => albumEntity.assetCountAsync;
String get id => albumEntity.id; String get id => albumEntity.id;

View File

@@ -131,13 +131,15 @@ class BackupNotifier extends StateNotifier<BackUpState> {
); );
if (state.backgroundBackup) { if (state.backgroundBackup) {
bool success = true;
if (!wasEnabled) { if (!wasEnabled) {
if (!await _backgroundService.isIgnoringBatteryOptimizations()) { if (!await _backgroundService.isIgnoringBatteryOptimizations()) {
onBatteryInfo(); onBatteryInfo();
} }
success &= await _backgroundService.enableService(immediate: true);
} }
final bool success = await _backgroundService.stopService() && success &= success &&
await _backgroundService.startService( await _backgroundService.configureService(
requireUnmetered: state.backupRequireWifi, requireUnmetered: state.backupRequireWifi,
requireCharging: state.backupRequireCharging, requireCharging: state.backupRequireCharging,
); );
@@ -155,7 +157,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
onError("backup_controller_page_background_configure_error"); onError("backup_controller_page_background_configure_error");
} }
} else { } else {
final bool success = await _backgroundService.stopService(); final bool success = await _backgroundService.disableService();
if (!success) { if (!success) {
state = state.copyWith(backgroundBackup: wasEnabled); state = state.copyWith(backgroundBackup: wasEnabled);
onError("backup_controller_page_background_configure_error"); onError("backup_controller_page_background_configure_error");
@@ -181,17 +183,21 @@ class BackupNotifier extends StateNotifier<BackUpState> {
for (AssetPathEntity album in albums) { for (AssetPathEntity album in albums) {
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album); AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
var assetList = var assetCountInAlbum = await album.assetCountAsync;
await album.getAssetListRange(start: 0, end: album.assetCount); if (assetCountInAlbum > 0) {
var assetList =
await album.getAssetListRange(start: 0, end: assetCountInAlbum);
if (assetList.isNotEmpty) { if (assetList.isNotEmpty) {
var thumbnailAsset = assetList.first; var thumbnailAsset = assetList.first;
var thumbnailData = await thumbnailAsset var thumbnailData = await thumbnailAsset
.thumbnailDataWithSize(const ThumbnailSize(512, 512)); .thumbnailDataWithSize(const ThumbnailSize(512, 512));
availableAlbum = availableAlbum.copyWith(thumbnailData: thumbnailData); availableAlbum =
availableAlbum.copyWith(thumbnailData: thumbnailData);
}
availableAlbums.add(availableAlbum);
} }
availableAlbums.add(availableAlbum);
} }
state = state.copyWith(availableAlbums: availableAlbums); state = state.copyWith(availableAlbums: availableAlbums);
@@ -294,14 +300,18 @@ class BackupNotifier extends StateNotifier<BackUpState> {
Set<AssetEntity> assetsFromExcludedAlbums = {}; Set<AssetEntity> assetsFromExcludedAlbums = {};
for (var album in state.selectedBackupAlbums) { for (var album in state.selectedBackupAlbums) {
var assets = await album.albumEntity var assets = await album.albumEntity.getAssetListRange(
.getAssetListRange(start: 0, end: album.assetCount); start: 0,
end: await album.albumEntity.assetCountAsync,
);
assetsFromSelectedAlbums.addAll(assets); assetsFromSelectedAlbums.addAll(assets);
} }
for (var album in state.excludedBackupAlbums) { for (var album in state.excludedBackupAlbums) {
var assets = await album.albumEntity var assets = await album.albumEntity.getAssetListRange(
.getAssetListRange(start: 0, end: album.assetCount); start: 0,
end: await album.albumEntity.assetCountAsync,
);
assetsFromExcludedAlbums.addAll(assets); assetsFromExcludedAlbums.addAll(assets);
} }
@@ -351,11 +361,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final bool isEnabled = await _backgroundService.isBackgroundBackupEnabled(); final bool isEnabled = await _backgroundService.isBackgroundBackupEnabled();
state = state.copyWith(backgroundBackup: isEnabled); state = state.copyWith(backgroundBackup: isEnabled);
if (state.backupProgress != BackUpProgressEnum.inBackground) { if (state.backupProgress != BackUpProgressEnum.inBackground) {
await Future.wait([ await _getBackupAlbumsInfo();
_getBackupAlbumsInfo(), await _updateServerInfo();
_updateServerInfo(),
]);
await _updateBackupAssetCount(); await _updateBackupAssetCount();
} }
} }

View File

@@ -127,7 +127,9 @@ class BackupService {
for (int i = 0; i < albums.length; i++) { for (int i = 0; i < albums.length; i++) {
final AssetPathEntity? a = albums[i]; final AssetPathEntity? a = albums[i];
if (a != null && a.lastModified?.isBefore(lastBackup[i]) != true) { if (a != null && a.lastModified?.isBefore(lastBackup[i]) != true) {
result.addAll(await a.getAssetListRange(start: 0, end: a.assetCount)); result.addAll(
await a.getAssetListRange(start: 0, end: await a.assetCountAsync),
);
lastBackup[i] = now; lastBackup[i] = now;
} }
} }

View File

@@ -1,5 +1,3 @@
import 'dart:typed_data';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -205,15 +203,23 @@ class AlbumInfoCard extends HookConsumerWidget {
), ),
Padding( Padding(
padding: const EdgeInsets.only(top: 2.0), padding: const EdgeInsets.only(top: 2.0),
child: Text( child: FutureBuilder(
albumInfo.assetCount.toString() + builder: ((context, snapshot) {
(albumInfo.isAll if (snapshot.hasData) {
? " (${'backup_all'.tr()})" return Text(
: ""), snapshot.data.toString() +
style: TextStyle( (albumInfo.isAll
fontSize: 12, ? " (${'backup_all'.tr()})"
color: Colors.grey[600], : ""),
), style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
);
}
return const Text("0");
}),
future: albumInfo.assetCount,
), ),
) )
], ],

View File

@@ -173,19 +173,19 @@ class BackupControllerPage extends HookConsumerWidget {
).tr(), ).tr(),
), ),
actions: [ actions: [
TextButton( OutlinedButton(
onPressed: () => launchUrl( onPressed: () => launchUrl(
Uri.parse('https://dontkillmyapp.com'), Uri.parse('https://dontkillmyapp.com'),
mode: LaunchMode.externalApplication), mode: LaunchMode.externalApplication,
child: Text( ),
child: const Text(
"backup_controller_page_background_battery_info_link", "backup_controller_page_background_battery_info_link",
style: TextStyle(color: buttonTextColor),
).tr(), ).tr(),
), ),
TextButton( ElevatedButton(
child: Text( child: const Text(
'backup_controller_page_background_battery_info_ok', 'backup_controller_page_background_battery_info_ok',
style: TextStyle(color: buttonTextColor), style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
).tr(), ).tr(),
onPressed: () { onPressed: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
@@ -508,7 +508,7 @@ class BackupControllerPage extends HookConsumerWidget {
DateTime.parse( DateTime.parse(
backupState.currentUploadAsset.createdAt backupState.currentUploadAsset.createdAt
.toString(), .toString(),
), ).toLocal(),
) )
], ],
), ),
@@ -636,8 +636,8 @@ class BackupControllerPage extends HookConsumerWidget {
backupState.backupProgress == BackUpProgressEnum.inProgress backupState.backupProgress == BackUpProgressEnum.inProgress
? ElevatedButton( ? ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
primary: Colors.red[300], foregroundColor: Colors.grey[50],
onPrimary: Colors.grey[50], backgroundColor: Colors.red[300],
// padding: const EdgeInsets.all(14), // padding: const EdgeInsets.all(14),
), ),
onPressed: () { onPressed: () {

View File

@@ -90,7 +90,7 @@ class FailedBackupStatusPage extends HookConsumerWidget {
DateFormat.yMMMMd('en_US').format( DateFormat.yMMMMd('en_US').format(
DateTime.parse( DateTime.parse(
errorAsset.createdAt.toString(), errorAsset.createdAt.toString(),
), ).toLocal(),
), ),
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,

View File

@@ -0,0 +1,95 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:openapi/api.dart';
enum RenderAssetGridElementType {
assetRow,
dayTitle,
monthTitle;
}
class RenderAssetGridRow {
final List<AssetResponseDto> assets;
RenderAssetGridRow(this.assets);
}
class RenderAssetGridElement {
final RenderAssetGridElementType type;
final RenderAssetGridRow? assetRow;
final String? title;
final DateTime date;
final List<AssetResponseDto>? relatedAssetList;
RenderAssetGridElement(
this.type, {
this.assetRow,
this.title,
required this.date,
this.relatedAssetList,
});
}
final renderListProvider = StateProvider((ref) {
var assetGroups = ref.watch(assetGroupByDateTimeProvider);
var settings = ref.watch(appSettingsServiceProvider);
final assetsPerRow = settings.getSetting(AppSettingsEnum.tilesPerRow);
List<RenderAssetGridElement> elements = [];
DateTime? lastDate;
assetGroups.forEach((groupName, assets) {
try {
final date = DateTime.parse(groupName);
if (lastDate == null || lastDate!.month != date.month) {
elements.add(
RenderAssetGridElement(
RenderAssetGridElementType.monthTitle,
title: groupName,
date: date,
),
);
}
// Add group title
elements.add(
RenderAssetGridElement(
RenderAssetGridElementType.dayTitle,
title: groupName,
date: date,
relatedAssetList: assets,
),
);
// Add rows
int cursor = 0;
while (cursor < assets.length) {
int rowElements = min(assets.length - cursor, assetsPerRow);
final rowElement = RenderAssetGridElement(
RenderAssetGridElementType.assetRow,
date: date,
assetRow: RenderAssetGridRow(
assets.sublist(cursor, cursor + rowElements),
),
);
elements.add(rowElement);
cursor += rowElements;
}
lastDate = date;
} catch (e) {
debugPrint(e.toString());
}
});
return elements;
});

View File

@@ -0,0 +1,107 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
import 'package:openapi/api.dart';
class DailyTitleText extends ConsumerWidget {
const DailyTitleText({
Key? key,
required this.isoDate,
required this.assetGroup,
}) : super(key: key);
final String isoDate;
final List<AssetResponseDto> assetGroup;
@override
Widget build(BuildContext context, WidgetRef ref) {
var currentYear = DateTime.now().year;
var groupYear = DateTime.parse(isoDate).year;
var formatDateTemplate = currentYear == groupYear
? "daily_title_text_date".tr()
: "daily_title_text_date_year".tr();
var dateText =
DateFormat(formatDateTemplate).format(DateTime.parse(isoDate));
var isMultiSelectEnable =
ref.watch(homePageStateProvider).isMultiSelectEnable;
var selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup;
var selectedItems = ref.watch(homePageStateProvider).selectedItems;
void _handleTitleIconClick() {
if (isMultiSelectEnable &&
selectedDateGroup.contains(dateText) &&
selectedDateGroup.length == 1 &&
selectedItems.length <= assetGroup.length) {
// Multi select is active - click again on the icon while it is the only active group -> disable multi select
ref.watch(homePageStateProvider.notifier).disableMultiSelect();
} else if (isMultiSelectEnable &&
selectedDateGroup.contains(dateText) &&
selectedItems.length != assetGroup.length) {
// Multi select is active - click again on the icon while it is not the only active group -> remove that group from selected group/items
ref
.watch(homePageStateProvider.notifier)
.removeSelectedDateGroup(dateText);
ref
.watch(homePageStateProvider.notifier)
.removeMultipleSelectedItem(assetGroup);
} else if (isMultiSelectEnable &&
selectedDateGroup.contains(dateText) &&
selectedDateGroup.length > 1) {
ref
.watch(homePageStateProvider.notifier)
.removeSelectedDateGroup(dateText);
ref
.watch(homePageStateProvider.notifier)
.removeMultipleSelectedItem(assetGroup);
} else if (isMultiSelectEnable && !selectedDateGroup.contains(dateText)) {
ref
.watch(homePageStateProvider.notifier)
.addSelectedDateGroup(dateText);
ref
.watch(homePageStateProvider.notifier)
.addMultipleSelectedItems(assetGroup);
} else {
ref
.watch(homePageStateProvider.notifier)
.enableMultiSelect(assetGroup.toSet());
ref
.watch(homePageStateProvider.notifier)
.addSelectedDateGroup(dateText);
}
}
return Padding(
padding: const EdgeInsets.only(
top: 29.0,
bottom: 29.0,
left: 12.0,
right: 12.0,
),
child: Row(
children: [
Text(
dateText,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
GestureDetector(
onTap: _handleTitleIconClick,
child: isMultiSelectEnable && selectedDateGroup.contains(dateText)
? Icon(
Icons.check_circle_rounded,
color: Theme.of(context).primaryColor,
)
: const Icon(
Icons.check_circle_outline_rounded,
color: Colors.grey,
),
)
],
),
);
}
}

View File

@@ -0,0 +1,535 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
/// Build the Scroll Thumb and label using the current configuration
typedef ScrollThumbBuilder = Widget Function(
Color backgroundColor,
Animation<double> thumbAnimation,
Animation<double> labelAnimation,
double height, {
Text? labelText,
BoxConstraints? labelConstraints,
});
/// Build a Text widget using the current scroll offset
typedef LabelTextBuilder = Text Function(int item);
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
/// for quick navigation of the BoxScrollView.
class DraggableScrollbar extends StatefulWidget {
/// The view that will be scrolled with the scroll thumb
final ScrollablePositionedList child;
final ItemPositionsListener itemPositionsListener;
/// A function that builds a thumb using the current configuration
final ScrollThumbBuilder scrollThumbBuilder;
/// The height of the scroll thumb
final double heightScrollThumb;
/// The background color of the label and thumb
final Color backgroundColor;
/// The amount of padding that should surround the thumb
final EdgeInsetsGeometry? padding;
/// Determines how quickly the scrollbar will animate in and out
final Duration scrollbarAnimationDuration;
/// How long should the thumb be visible before fading out
final Duration scrollbarTimeToFade;
/// Build a Text widget from the current offset in the BoxScrollView
final LabelTextBuilder? labelTextBuilder;
/// Determines box constraints for Container displaying label
final BoxConstraints? labelConstraints;
/// The ScrollController for the BoxScrollView
final ItemScrollController controller;
/// Determines scrollThumb displaying. If you draw own ScrollThumb and it is true you just don't need to use animation parameters in [scrollThumbBuilder]
final bool alwaysVisibleScrollThumb;
final Function(bool scrolling) scrollStateListener;
DraggableScrollbar.semicircle({
Key? key,
Key? scrollThumbKey,
this.alwaysVisibleScrollThumb = false,
required this.child,
required this.controller,
required this.itemPositionsListener,
required this.scrollStateListener,
this.heightScrollThumb = 48.0,
this.backgroundColor = Colors.white,
this.padding,
this.scrollbarAnimationDuration = const Duration(milliseconds: 300),
this.scrollbarTimeToFade = const Duration(milliseconds: 600),
this.labelTextBuilder,
this.labelConstraints,
}) : assert(child.scrollDirection == Axis.vertical),
scrollThumbBuilder = _thumbSemicircleBuilder(
heightScrollThumb * 0.6,
scrollThumbKey,
alwaysVisibleScrollThumb,
),
super(key: key);
@override
DraggableScrollbarState createState() => DraggableScrollbarState();
static buildScrollThumbAndLabel({
required Widget scrollThumb,
required Color backgroundColor,
required Animation<double>? thumbAnimation,
required Animation<double>? labelAnimation,
required Text? labelText,
required BoxConstraints? labelConstraints,
required bool alwaysVisibleScrollThumb,
}) {
var scrollThumbAndLabel = labelText == null
? scrollThumb
: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [
ScrollLabel(
animation: labelAnimation,
backgroundColor: backgroundColor,
constraints: labelConstraints,
child: labelText,
),
scrollThumb,
],
);
if (alwaysVisibleScrollThumb) {
return scrollThumbAndLabel;
}
return SlideFadeTransition(
animation: thumbAnimation!,
child: scrollThumbAndLabel,
);
}
static ScrollThumbBuilder _thumbSemicircleBuilder(
double width,
Key? scrollThumbKey,
bool alwaysVisibleScrollThumb,
) {
return (
Color backgroundColor,
Animation<double> thumbAnimation,
Animation<double> labelAnimation,
double height, {
Text? labelText,
BoxConstraints? labelConstraints,
}) {
final scrollThumb = CustomPaint(
key: scrollThumbKey,
foregroundPainter: ArrowCustomPainter(Colors.white),
child: Material(
elevation: 4.0,
color: backgroundColor,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(height),
bottomLeft: Radius.circular(height),
topRight: const Radius.circular(4.0),
bottomRight: const Radius.circular(4.0),
),
child: Container(
constraints: BoxConstraints.tight(Size(width, height)),
),
),
);
return buildScrollThumbAndLabel(
scrollThumb: scrollThumb,
backgroundColor: backgroundColor,
thumbAnimation: thumbAnimation,
labelAnimation: labelAnimation,
labelText: labelText,
labelConstraints: labelConstraints,
alwaysVisibleScrollThumb: alwaysVisibleScrollThumb,
);
};
}
}
class ScrollLabel extends StatelessWidget {
final Animation<double>? animation;
final Color backgroundColor;
final Text child;
final BoxConstraints? constraints;
static const BoxConstraints _defaultConstraints =
BoxConstraints.tightFor(width: 72.0, height: 28.0);
const ScrollLabel({
Key? key,
required this.child,
required this.animation,
required this.backgroundColor,
this.constraints = _defaultConstraints,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: animation!,
child: Container(
margin: const EdgeInsets.only(right: 12.0),
child: Material(
elevation: 4.0,
color: backgroundColor,
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
child: Container(
constraints: constraints ?? _defaultConstraints,
padding: const EdgeInsets.symmetric(horizontal: 10.0),
alignment: Alignment.center,
child: child,
),
),
),
);
}
}
class DraggableScrollbarState extends State<DraggableScrollbar>
with TickerProviderStateMixin {
late double _barOffset;
late bool _isDragInProcess;
late int _currentItem;
late AnimationController _thumbAnimationController;
late Animation<double> _thumbAnimation;
late AnimationController _labelAnimationController;
late Animation<double> _labelAnimation;
Timer? _fadeoutTimer;
@override
void initState() {
super.initState();
_barOffset = 0.0;
_isDragInProcess = false;
_currentItem = 0;
_thumbAnimationController = AnimationController(
vsync: this,
duration: widget.scrollbarAnimationDuration,
);
_thumbAnimation = CurvedAnimation(
parent: _thumbAnimationController,
curve: Curves.fastOutSlowIn,
);
_labelAnimationController = AnimationController(
vsync: this,
duration: widget.scrollbarAnimationDuration,
);
_labelAnimation = CurvedAnimation(
parent: _labelAnimationController,
curve: Curves.fastOutSlowIn,
);
}
@override
void dispose() {
_thumbAnimationController.dispose();
_labelAnimationController.dispose();
_fadeoutTimer?.cancel();
super.dispose();
}
double get barMaxScrollExtent =>
(context.size?.height ?? 0) - widget.heightScrollThumb;
double get barMinScrollExtent => 0;
int get maxItemCount => widget.child.itemCount;
@override
Widget build(BuildContext context) {
Text? labelText;
if (widget.labelTextBuilder != null && _isDragInProcess) {
labelText = widget.labelTextBuilder!(_currentItem);
}
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
//print("LayoutBuilder constraints=$constraints");
return NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
changePosition(notification);
return false;
},
child: Stack(
children: <Widget>[
RepaintBoundary(
child: widget.child,
),
RepaintBoundary(
child: GestureDetector(
onVerticalDragStart: _onVerticalDragStart,
onVerticalDragUpdate: _onVerticalDragUpdate,
onVerticalDragEnd: _onVerticalDragEnd,
child: Container(
alignment: Alignment.topRight,
margin: EdgeInsets.only(top: _barOffset),
padding: widget.padding,
child: widget.scrollThumbBuilder(
widget.backgroundColor,
_thumbAnimation,
_labelAnimation,
widget.heightScrollThumb,
labelText: labelText,
labelConstraints: widget.labelConstraints,
),
),
),
),
],
),
);
},
);
}
// scroll bar has received notification that it's view was scrolled
// so it should also changes his position
// but only if it isn't dragged
changePosition(ScrollNotification notification) {
if (_isDragInProcess) {
return;
}
setState(() {
int firstItemIndex =
widget.itemPositionsListener.itemPositions.value.first.index;
if (notification is ScrollUpdateNotification) {
_barOffset = (firstItemIndex / maxItemCount) * barMaxScrollExtent;
if (_barOffset < barMinScrollExtent) {
_barOffset = barMinScrollExtent;
}
if (_barOffset > barMaxScrollExtent) {
_barOffset = barMaxScrollExtent;
}
}
if (notification is ScrollUpdateNotification ||
notification is OverscrollNotification) {
if (_thumbAnimationController.status != AnimationStatus.forward) {
_thumbAnimationController.forward();
}
if (itemPos < maxItemCount) {
_currentItem = itemPos;
}
_fadeoutTimer?.cancel();
_fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
_thumbAnimationController.reverse();
_labelAnimationController.reverse();
_fadeoutTimer = null;
});
}
});
}
void _onVerticalDragStart(DragStartDetails details) {
setState(() {
_isDragInProcess = true;
_labelAnimationController.forward();
_fadeoutTimer?.cancel();
});
widget.scrollStateListener(true);
}
int get itemPos {
int numberOfItems = widget.child.itemCount;
return ((_barOffset / barMaxScrollExtent) * numberOfItems).toInt();
}
void _jumpToBarPos() {
if (itemPos > maxItemCount - 1) {
return;
}
_currentItem = itemPos;
widget.controller.jumpTo(
index: itemPos,
);
}
Timer? dragHaltTimer;
int lastTimerPos = 0;
void _onVerticalDragUpdate(DragUpdateDetails details) {
setState(() {
if (_thumbAnimationController.status != AnimationStatus.forward) {
_thumbAnimationController.forward();
}
if (_isDragInProcess) {
_barOffset += details.delta.dy;
if (_barOffset < barMinScrollExtent) {
_barOffset = barMinScrollExtent;
}
if (_barOffset > barMaxScrollExtent) {
_barOffset = barMaxScrollExtent;
}
if (itemPos != lastTimerPos) {
lastTimerPos = itemPos;
dragHaltTimer?.cancel();
widget.scrollStateListener(true);
dragHaltTimer = Timer(
const Duration(milliseconds: 200),
() {
widget.scrollStateListener(false);
},
);
}
_jumpToBarPos();
}
});
}
void _onVerticalDragEnd(DragEndDetails details) {
_fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
_thumbAnimationController.reverse();
_labelAnimationController.reverse();
_fadeoutTimer = null;
});
setState(() {
_jumpToBarPos();
_isDragInProcess = false;
});
widget.scrollStateListener(false);
}
}
/// Draws 2 triangles like arrow up and arrow down
class ArrowCustomPainter extends CustomPainter {
Color color;
ArrowCustomPainter(this.color);
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = color;
const width = 12.0;
const height = 8.0;
final baseX = size.width / 2;
final baseY = size.height / 2;
canvas.drawPath(
_trianglePath(Offset(baseX, baseY - 2.0), width, height, true),
paint,
);
canvas.drawPath(
_trianglePath(Offset(baseX, baseY + 2.0), width, height, false),
paint,
);
}
static Path _trianglePath(Offset o, double width, double height, bool isUp) {
return Path()
..moveTo(o.dx, o.dy)
..lineTo(o.dx + width, o.dy)
..lineTo(o.dx + (width / 2), isUp ? o.dy - height : o.dy + height)
..close();
}
}
///This cut 2 lines in arrow shape
class ArrowClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
Path path = Path();
path.lineTo(0.0, size.height);
path.lineTo(size.width, size.height);
path.lineTo(size.width, 0.0);
path.lineTo(0.0, 0.0);
path.close();
double arrowWidth = 8.0;
double startPointX = (size.width - arrowWidth) / 2;
double startPointY = size.height / 2 - arrowWidth / 2;
path.moveTo(startPointX, startPointY);
path.lineTo(startPointX + arrowWidth / 2, startPointY - arrowWidth / 2);
path.lineTo(startPointX + arrowWidth, startPointY);
path.lineTo(startPointX + arrowWidth, startPointY + 1.0);
path.lineTo(
startPointX + arrowWidth / 2,
startPointY - arrowWidth / 2 + 1.0,
);
path.lineTo(startPointX, startPointY + 1.0);
path.close();
startPointY = size.height / 2 + arrowWidth / 2;
path.moveTo(startPointX + arrowWidth, startPointY);
path.lineTo(startPointX + arrowWidth / 2, startPointY + arrowWidth / 2);
path.lineTo(startPointX, startPointY);
path.lineTo(startPointX, startPointY - 1.0);
path.lineTo(
startPointX + arrowWidth / 2,
startPointY + arrowWidth / 2 - 1.0,
);
path.lineTo(startPointX + arrowWidth, startPointY - 1.0);
path.close();
return path;
}
@override
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
}
class SlideFadeTransition extends StatelessWidget {
final Animation<double> animation;
final Widget child;
const SlideFadeTransition({
Key? key,
required this.animation,
required this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) =>
animation.value == 0.0 ? const SizedBox() : child!,
child: SlideTransition(
position: Tween(
begin: const Offset(0.3, 0.0),
end: const Offset(0.0, 0.0),
).animate(animation),
child: FadeTransition(
opacity: animation,
child: child,
),
),
);
}
}

View File

@@ -0,0 +1,167 @@
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/src/widgets/framework.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/home_page_render_list_provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_list_v2/daily_title_text.dart';
import 'package:immich_mobile/modules/home/ui/asset_list_v2/draggable_scrollbar_custom.dart';
import 'package:openapi/api.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import '../thumbnail_image.dart';
class ImmichAssetGrid extends HookConsumerWidget {
final ItemScrollController _itemScrollController = ItemScrollController();
final ItemPositionsListener _itemPositionsListener =
ItemPositionsListener.create();
final List<RenderAssetGridElement> renderList;
final int assetsPerRow;
final double margin;
final bool showStorageIndicator;
ImmichAssetGrid({
super.key,
required this.renderList,
required this.assetsPerRow,
required this.showStorageIndicator,
this.margin = 5.0,
});
List<AssetResponseDto> get _assets {
return renderList
.map((e) {
if (e.type == RenderAssetGridElementType.assetRow) {
return e.assetRow!.assets;
} else {
return List<AssetResponseDto>.empty();
}
})
.flattened
.toList();
}
double _getItemSize(BuildContext context) {
return MediaQuery.of(context).size.width / assetsPerRow -
margin * (assetsPerRow - 1) / assetsPerRow;
}
Widget _buildThumbnailOrPlaceholder(
AssetResponseDto asset, bool placeholder) {
if (placeholder) {
return const DecoratedBox(
decoration: BoxDecoration(color: Colors.grey),
);
}
return ThumbnailImage(
asset: asset,
assetList: _assets,
showStorageIndicator: showStorageIndicator,
useGrayBoxPlaceholder: true,
);
}
Widget _buildAssetRow(
BuildContext context, RenderAssetGridRow row, bool scrolling) {
double size = _getItemSize(context);
return Row(
key: Key("asset-row-${row.assets.first.id}"),
children: row.assets.map((AssetResponseDto asset) {
bool last = asset == row.assets.last;
return Container(
key: Key("asset-${asset.id}"),
width: size,
height: size,
margin: EdgeInsets.only(top: margin, right: last ? 0.0 : margin),
child: _buildThumbnailOrPlaceholder(asset, scrolling),
);
}).toList(),
);
}
Widget _buildTitle(
BuildContext context, String title, List<AssetResponseDto> assets) {
return DailyTitleText(
isoDate: title,
assetGroup: assets,
);
}
Widget _buildMonthTitle(BuildContext context, String title) {
var monthTitleText = DateFormat("monthly_title_text_date_format".tr())
.format(DateTime.parse(title));
return Padding(
key: Key("month-$title"),
padding: const EdgeInsets.only(left: 12.0, top: 32),
child: Text(
monthTitleText,
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: Theme.of(context).textTheme.headline1?.color,
),
),
);
}
Widget _itemBuilder(BuildContext c, int position, bool scrolling) {
final item = renderList[position];
if (item.type == RenderAssetGridElementType.dayTitle) {
return _buildTitle(c, item.title!, item.relatedAssetList!);
} else if (item.type == RenderAssetGridElementType.monthTitle) {
return _buildMonthTitle(c, item.title!);
} else if (item.type == RenderAssetGridElementType.assetRow) {
return _buildAssetRow(c, item.assetRow!, scrolling);
}
return const Text("Invalid widget type!");
}
Text _labelBuilder(int pos) {
final date = renderList[pos].date;
return Text(DateFormat.yMMMd().format(date),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final scrolling = useState(false);
void dragScrolling(bool active) {
scrolling.value = active;
}
Widget itemBuilder(BuildContext c, int position) {
return _itemBuilder(c, position, scrolling.value);
}
return DraggableScrollbar.semicircle(
scrollStateListener: dragScrolling,
itemPositionsListener: _itemPositionsListener,
controller: _itemScrollController,
backgroundColor: Theme.of(context).hintColor,
labelTextBuilder: _labelBuilder,
labelConstraints: const BoxConstraints(maxHeight: 28),
scrollbarAnimationDuration: const Duration(seconds: 1),
scrollbarTimeToFade: const Duration(seconds: 4),
child: ScrollablePositionedList.builder(
itemBuilder: itemBuilder,
itemPositionsListener: _itemPositionsListener,
itemScrollController: _itemScrollController,
itemCount: renderList.length,
));
}
}

View File

@@ -21,8 +21,8 @@ class DailyTitleText extends ConsumerWidget {
var formatDateTemplate = currentYear == groupYear var formatDateTemplate = currentYear == groupYear
? "daily_title_text_date".tr() ? "daily_title_text_date".tr()
: "daily_title_text_date_year".tr(); : "daily_title_text_date_year".tr();
var dateText = var dateText = DateFormat(formatDateTemplate)
DateFormat(formatDateTemplate).format(DateTime.parse(isoDate)); .format(DateTime.parse(isoDate).toLocal());
var isMultiSelectEnable = var isMultiSelectEnable =
ref.watch(homePageStateProvider).isMultiSelectEnable; ref.watch(homePageStateProvider).isMultiSelectEnable;
var selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup; var selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup;

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/thumbnail_image.dart'; import 'package:immich_mobile/modules/home/ui/thumbnail_image.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -10,13 +9,11 @@ class ImageGrid extends ConsumerWidget {
final List<AssetResponseDto> sortedAssetGroup; final List<AssetResponseDto> sortedAssetGroup;
final int tilesPerRow; final int tilesPerRow;
final bool showStorageIndicator; final bool showStorageIndicator;
final BaseCacheManager? cacheManager;
ImageGrid({ ImageGrid({
Key? key, Key? key,
required this.assetGroup, required this.assetGroup,
required this.sortedAssetGroup, required this.sortedAssetGroup,
this.cacheManager,
this.tilesPerRow = 4, this.tilesPerRow = 4,
this.showStorageIndicator = true, this.showStorageIndicator = true,
}) : super(key: key); }) : super(key: key);
@@ -36,35 +33,10 @@ class ImageGrid extends ConsumerWidget {
var assetType = assetGroup[index].type; var assetType = assetGroup[index].type;
return GestureDetector( return GestureDetector(
onTap: () {}, onTap: () {},
child: Stack( child: ThumbnailImage(
children: [ asset: assetGroup[index],
ThumbnailImage( assetList: sortedAssetGroup,
cacheManager: cacheManager, showStorageIndicator: showStorageIndicator,
asset: assetGroup[index],
assetList: sortedAssetGroup,
showStorageIndicator: showStorageIndicator,
),
if (assetType != AssetTypeEnum.IMAGE)
Positioned(
top: 5,
right: 5,
child: Row(
children: [
Text(
assetGroup[index].duration.toString().substring(0, 7),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
),
const Icon(
Icons.play_circle_outline_rounded,
color: Colors.white,
),
],
),
),
],
), ),
); );
}, },

View File

@@ -21,7 +21,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final BackUpState backupState = ref.watch(backupProvider); final BackUpState backupState = ref.watch(backupProvider);
bool isEnableAutoBackup = bool isEnableAutoBackup = backupState.backgroundBackup ||
ref.watch(authenticationProvider).deviceInfo.isAutoBackup; ref.watch(authenticationProvider).deviceInfo.isAutoBackup;
final ServerInfoState serverInfoState = ref.watch(serverInfoProvider); final ServerInfoState serverInfoState = ref.watch(serverInfoProvider);

View File

@@ -12,7 +12,7 @@ class MonthlyTitleText extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var monthTitleText = DateFormat("monthly_title_text_date_format".tr()) var monthTitleText = DateFormat("monthly_title_text_date_format".tr())
.format(DateTime.parse(isoDate)); .format(DateTime.parse(isoDate).toLocal());
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Padding( child: Padding(

View File

@@ -2,8 +2,6 @@ import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
@@ -17,14 +15,14 @@ class ThumbnailImage extends HookConsumerWidget {
final AssetResponseDto asset; final AssetResponseDto asset;
final List<AssetResponseDto> assetList; final List<AssetResponseDto> assetList;
final bool showStorageIndicator; final bool showStorageIndicator;
final BaseCacheManager? cacheManager; final bool useGrayBoxPlaceholder;
const ThumbnailImage({ const ThumbnailImage({
Key? key, Key? key,
required this.asset, required this.asset,
required this.assetList, required this.assetList,
this.cacheManager,
this.showStorageIndicator = true, this.showStorageIndicator = true,
this.useGrayBoxPlaceholder = false,
}) : super(key: key); }) : super(key: key);
@override @override
@@ -36,7 +34,7 @@ class ThumbnailImage extends HookConsumerWidget {
ref.watch(homePageStateProvider).isMultiSelectEnable; ref.watch(homePageStateProvider).isMultiSelectEnable;
var deviceId = ref.watch(authenticationProvider).deviceId; var deviceId = ref.watch(authenticationProvider).deviceId;
Widget _buildSelectionIcon(AssetResponseDto asset) { Widget buildSelectionIcon(AssetResponseDto asset) {
if (selectedAsset.contains(asset)) { if (selectedAsset.contains(asset)) {
return Icon( return Icon(
Icons.check_circle, Icons.check_circle,
@@ -52,7 +50,6 @@ class ThumbnailImage extends HookConsumerWidget {
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
debugPrint("View ${asset.id}");
if (isMultiSelectEnable && if (isMultiSelectEnable &&
selectedAsset.contains(asset) && selectedAsset.contains(asset) &&
selectedAsset.length == 1) { selectedAsset.length == 1) {
@@ -95,26 +92,35 @@ class ThumbnailImage extends HookConsumerWidget {
: const Border(), : const Border(),
), ),
child: CachedNetworkImage( child: CachedNetworkImage(
cacheKey: asset.id, cacheKey: 'thumbnail-image-${asset.id}',
cacheManager: cacheManager,
width: 300, width: 300,
height: 300, height: 300,
memCacheHeight: asset.type == AssetTypeEnum.IMAGE ? 250 : 400, memCacheHeight: 200,
maxWidthDiskCache: 200,
maxHeightDiskCache: 200,
fit: BoxFit.cover, fit: BoxFit.cover,
imageUrl: thumbnailRequestUrl, imageUrl: thumbnailRequestUrl,
httpHeaders: { httpHeaders: {
"Authorization": "Bearer ${box.get(accessTokenKey)}" "Authorization": "Bearer ${box.get(accessTokenKey)}"
}, },
fadeInDuration: const Duration(milliseconds: 250), fadeInDuration: const Duration(milliseconds: 250),
progressIndicatorBuilder: (context, url, downloadProgress) => progressIndicatorBuilder: (context, url, downloadProgress) {
Transform.scale( if (useGrayBoxPlaceholder) {
scale: 0.2, return const DecoratedBox(
child: CircularProgressIndicator( decoration: BoxDecoration(color: Colors.grey),
value: downloadProgress.progress, );
), }
), return Transform.scale(
scale: 0.2,
child: CircularProgressIndicator(
value: downloadProgress.progress,
),
);
},
errorWidget: (context, url, error) { errorWidget: (context, url, error) {
debugPrint("Error getting thumbnail $url = $error"); debugPrint("Error getting thumbnail $url = $error");
CachedNetworkImage.evictFromCache(thumbnailRequestUrl);
return Icon( return Icon(
Icons.image_not_supported_outlined, Icons.image_not_supported_outlined,
color: Theme.of(context).primaryColor, color: Theme.of(context).primaryColor,
@@ -127,7 +133,7 @@ class ThumbnailImage extends HookConsumerWidget {
padding: const EdgeInsets.all(3.0), padding: const EdgeInsets.all(3.0),
child: Align( child: Align(
alignment: Alignment.topLeft, alignment: Alignment.topLeft,
child: _buildSelectionIcon(asset), child: buildSelectionIcon(asset),
), ),
), ),
if (showStorageIndicator) if (showStorageIndicator)
@@ -141,7 +147,27 @@ class ThumbnailImage extends HookConsumerWidget {
color: Colors.white, color: Colors.white,
size: 18, size: 18,
), ),
) ),
if (asset.type != AssetTypeEnum.IMAGE)
Positioned(
top: 5,
right: 5,
child: Row(
children: [
Text(
asset.duration.toString().substring(0, 7),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
),
const Icon(
Icons.play_circle_outline_rounded,
color: Colors.white,
),
],
),
),
], ],
), ),
), ),

View File

@@ -1,12 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/home_page_render_list_provider.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart'; import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart'; import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
import 'package:immich_mobile/modules/home/ui/daily_title_text.dart'; import 'package:immich_mobile/modules/home/ui/daily_title_text.dart';
import 'package:immich_mobile/modules/home/ui/disable_multi_select_button.dart'; import 'package:immich_mobile/modules/home/ui/disable_multi_select_button.dart';
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart'; import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
import 'package:immich_mobile/modules/home/ui/image_grid.dart'; import 'package:immich_mobile/modules/home/ui/image_grid.dart';
import 'package:immich_mobile/modules/home/ui/asset_list_v2/immich_asset_grid.dart';
import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart'; import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart';
import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart'; import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart'; import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart';
@@ -16,7 +18,6 @@ import 'package:immich_mobile/modules/settings/services/app_settings.service.dar
import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart'; import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/services/cache.service.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
class HomePage extends HookConsumerWidget { class HomePage extends HookConsumerWidget {
@@ -25,7 +26,8 @@ class HomePage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final appSettingService = ref.watch(appSettingsServiceProvider); final appSettingService = ref.watch(appSettingsServiceProvider);
final cacheService = ref.watch(cacheServiceProvider);
var renderList = ref.watch(renderListProvider);
ScrollController scrollController = useScrollController(); ScrollController scrollController = useScrollController();
var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider); var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider);
@@ -91,7 +93,6 @@ class HomePage extends HookConsumerWidget {
imageGridGroup.add( imageGridGroup.add(
ImageGrid( ImageGrid(
cacheManager: cacheService.getCache(CacheType.thumbnail),
assetGroup: immichAssetList, assetGroup: immichAssetList,
sortedAssetGroup: sortedAssetList, sortedAssetGroup: sortedAssetList,
tilesPerRow: tilesPerRow:
@@ -123,6 +124,31 @@ class HomePage extends HookConsumerWidget {
); );
} }
_buildAssetGrid() {
if (appSettingService
.getSetting(AppSettingsEnum.useExperimentalAssetGrid)) {
return ImmichAssetGrid(
renderList: renderList,
assetsPerRow:
appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
showStorageIndicator: appSettingService
.getSetting(AppSettingsEnum.storageIndicator),
);
} else {
return DraggableScrollbar.semicircle(
backgroundColor: Theme.of(context).hintColor,
controller: scrollController,
heightScrollThumb: 48.0,
child: CustomScrollView(
controller: scrollController,
slivers: [
...imageGridGroup,
],
),
);
}
}
return SafeArea( return SafeArea(
bottom: !isMultiSelectEnable, bottom: !isMultiSelectEnable,
top: !isMultiSelectEnable, top: !isMultiSelectEnable,
@@ -135,17 +161,7 @@ class HomePage extends HookConsumerWidget {
), ),
Padding( Padding(
padding: const EdgeInsets.only(top: 60.0, bottom: 0.0), padding: const EdgeInsets.only(top: 60.0, bottom: 0.0),
child: DraggableScrollbar.semicircle( child: _buildAssetGrid(),
backgroundColor: Theme.of(context).hintColor,
controller: scrollController,
heightScrollThumb: 48.0,
child: CustomScrollView(
controller: scrollController,
slivers: [
...imageGridGroup,
],
),
),
), ),
if (isMultiSelectEnable) ...[ if (isMultiSelectEnable) ...[
_buildSelectedItemCountIndicator(), _buildSelectedItemCountIndicator(),

View File

@@ -62,7 +62,7 @@ final searchResultGroupByDateTimeProvider = StateProvider((ref) {
(a, b) => b.compareTo(a), (a, b) => b.compareTo(a),
); );
return assets.groupListsBy( return assets.groupListsBy(
(element) => (element) => DateFormat('y-MM-dd')
DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)), .format(DateTime.parse(element.createdAt).toLocal()),
); );
}); });

View File

@@ -99,9 +99,9 @@ class SearchPage extends HookConsumerWidget {
_buildThings() { _buildThings() {
return curatedObjects.when( return curatedObjects.when(
loading: () => const SizedBox( loading: () => SizedBox(
height: 200, height: imageSize,
child: Center(child: ImmichLoadingIndicator()), child: const Center(child: ImmichLoadingIndicator()),
), ),
error: (err, stack) => Text('Error: $err'), error: (err, stack) => Text('Error: $err'),
data: (objects) { data: (objects) {
@@ -133,8 +133,7 @@ class SearchPage extends HookConsumerWidget {
), ),
) )
: SizedBox( : SizedBox(
// height: imageSize, height: imageSize,
width: imageSize,
child: ListView.builder( child: ListView.builder(
padding: const EdgeInsets.only(left: 16), padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,

View File

@@ -10,7 +10,8 @@ enum AppSettingsEnum<T> {
storageIndicator<bool>("storageIndicator", true), storageIndicator<bool>("storageIndicator", true),
thumbnailCacheSize<int>("thumbnailCacheSize", 10000), thumbnailCacheSize<int>("thumbnailCacheSize", 10000),
imageCacheSize<int>("imageCacheSize", 350), imageCacheSize<int>("imageCacheSize", 350),
albumThumbnailCacheSize<int>("albumThumbnailCacheSize", 200); albumThumbnailCacheSize<int>("albumThumbnailCacheSize", 200),
useExperimentalAssetGrid<bool>("useExperimentalAssetGrid", false);
const AppSettingsEnum(this.hiveKey, this.defaultValue); const AppSettingsEnum(this.hiveKey, this.defaultValue);

View File

@@ -15,7 +15,6 @@ class CacheSettings extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final CacheService cacheService = ref.watch(cacheServiceProvider); final CacheService cacheService = ref.watch(cacheServiceProvider);
final clearCacheState = useState(false); final clearCacheState = useState(false);
Future<void> clearCache() async { Future<void> clearCache() async {

View File

@@ -0,0 +1,80 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
class ExperimentalSettings extends HookConsumerWidget {
const ExperimentalSettings({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final appSettingService = ref.watch(appSettingsServiceProvider);
final useExperimentalAssetGrid = useState(false);
useEffect(
() {
useExperimentalAssetGrid.value = appSettingService
.getSetting(AppSettingsEnum.useExperimentalAssetGrid);
return null;
},
[],
);
void changeUseExperimentalAssetGrid(bool status) {
useExperimentalAssetGrid.value = status;
appSettingService.setSetting(
AppSettingsEnum.useExperimentalAssetGrid,
status,
);
ImmichToast.show(
context: context,
msg: "settings_require_restart".tr(),
gravity: ToastGravity.BOTTOM,
);
}
return ExpansionTile(
textColor: Theme.of(context).primaryColor,
title: const Text(
'experimental_settings_title',
style: TextStyle(
fontWeight: FontWeight.bold,
),
).tr(),
subtitle: const Text(
'experimental_settings_subtitle',
style: TextStyle(
fontSize: 13,
),
).tr(),
children: [
SwitchListTile.adaptive(
activeColor: Theme.of(context).primaryColor,
title: const Text(
"experimental_settings_new_asset_list_title",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
).tr(),
subtitle: const Text(
"experimental_settings_new_asset_list_subtitle",
style: TextStyle(
fontSize: 12,
),
).tr(),
value: useExperimentalAssetGrid.value,
onChanged: changeUseExperimentalAssetGrid,
),
],
);
}
}

View File

@@ -4,7 +4,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_settings.dart'; import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_settings.dart';
import 'package:immich_mobile/modules/settings/ui/cache_settings/cache_settings.dart'; import 'package:immich_mobile/modules/settings/ui/experimental_settings/experimental_settings.dart';
import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart'; import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart';
import 'package:immich_mobile/modules/settings/ui/notification_setting/notification_setting.dart'; import 'package:immich_mobile/modules/settings/ui/notification_setting/notification_setting.dart';
import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart'; import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart';
@@ -42,8 +42,8 @@ class SettingsPage extends HookConsumerWidget {
const ImageViewerQualitySetting(), const ImageViewerQualitySetting(),
const ThemeSetting(), const ThemeSetting(),
const AssetListSettings(), const AssetListSettings(),
const CacheSettings(),
if (Platform.isAndroid) const NotificationSetting(), if (Platform.isAndroid) const NotificationSetting(),
const ExperimentalSettings(),
], ],
).toList(), ).toList(),
], ],

View File

@@ -59,8 +59,6 @@ class _$AppRouter extends RootStackRouter {
authToken: args.authToken, authToken: args.authToken,
isZoomedFunction: args.isZoomedFunction, isZoomedFunction: args.isZoomedFunction,
isZoomedListener: args.isZoomedListener, isZoomedListener: args.isZoomedListener,
onLoadingCompleted: args.onLoadingCompleted,
onLoadingStart: args.onLoadingStart,
threeStageLoading: args.threeStageLoading)); threeStageLoading: args.threeStageLoading));
}, },
VideoViewerRoute.name: (routeData) { VideoViewerRoute.name: (routeData) {
@@ -297,8 +295,6 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
required String authToken, required String authToken,
required void Function() isZoomedFunction, required void Function() isZoomedFunction,
required ValueNotifier<bool> isZoomedListener, required ValueNotifier<bool> isZoomedListener,
required void Function() onLoadingCompleted,
required void Function() onLoadingStart,
required bool threeStageLoading}) required bool threeStageLoading})
: super(ImageViewerRoute.name, : super(ImageViewerRoute.name,
path: '/image-viewer-page', path: '/image-viewer-page',
@@ -309,8 +305,6 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
authToken: authToken, authToken: authToken,
isZoomedFunction: isZoomedFunction, isZoomedFunction: isZoomedFunction,
isZoomedListener: isZoomedListener, isZoomedListener: isZoomedListener,
onLoadingCompleted: onLoadingCompleted,
onLoadingStart: onLoadingStart,
threeStageLoading: threeStageLoading)); threeStageLoading: threeStageLoading));
static const String name = 'ImageViewerRoute'; static const String name = 'ImageViewerRoute';
@@ -324,8 +318,6 @@ class ImageViewerRouteArgs {
required this.authToken, required this.authToken,
required this.isZoomedFunction, required this.isZoomedFunction,
required this.isZoomedListener, required this.isZoomedListener,
required this.onLoadingCompleted,
required this.onLoadingStart,
required this.threeStageLoading}); required this.threeStageLoading});
final Key? key; final Key? key;
@@ -340,15 +332,11 @@ class ImageViewerRouteArgs {
final ValueNotifier<bool> isZoomedListener; final ValueNotifier<bool> isZoomedListener;
final void Function() onLoadingCompleted;
final void Function() onLoadingStart;
final bool threeStageLoading; final bool threeStageLoading;
@override @override
String toString() { String toString() {
return 'ImageViewerRouteArgs{key: $key, heroTag: $heroTag, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener, onLoadingCompleted: $onLoadingCompleted, onLoadingStart: $onLoadingStart, threeStageLoading: $threeStageLoading}'; return 'ImageViewerRouteArgs{key: $key, heroTag: $heroTag, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener, threeStageLoading: $threeStageLoading}';
} }
} }

View File

@@ -81,8 +81,8 @@ final assetGroupByDateTimeProvider = StateProvider((ref) {
(a, b) => b.compareTo(a), (a, b) => b.compareTo(a),
); );
return assets.groupListsBy( return assets.groupListsBy(
(element) => (element) => DateFormat('y-MM-dd')
DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)), .format(DateTime.parse(element.createdAt).toLocal()),
); );
}); });
@@ -95,7 +95,7 @@ final assetGroupByMonthYearProvider = StateProvider((ref) {
); );
return assets.groupListsBy( return assets.groupListsBy(
(element) => (element) => DateFormat('MMMM, y')
DateFormat('MMMM, y').format(DateTime.parse(element.createdAt)), .format(DateTime.parse(element.createdAt).toLocal()),
); );
}); });

View File

@@ -7,7 +7,6 @@ import 'package:immich_mobile/utils/immich_cache_info_repository.dart';
enum CacheType { enum CacheType {
// Shared cache for asset thumbnails in various modules // Shared cache for asset thumbnails in various modules
thumbnail, thumbnail,
imageViewerPreview, imageViewerPreview,
imageViewerFull, imageViewerFull,
albumThumbnail, albumThumbnail,
@@ -67,7 +66,10 @@ class CacheService {
} }
BaseCacheManager _getDefaultCache( BaseCacheManager _getDefaultCache(
String cacheName, int size, CacheInfoRepository repo) { String cacheName,
int size,
CacheInfoRepository repo,
) {
return CacheManager( return CacheManager(
Config( Config(
cacheName, cacheName,

View File

@@ -10,6 +10,7 @@ class ImmichToast {
ToastType toastType = ToastType.info, ToastType toastType = ToastType.info,
ToastGravity gravity = ToastGravity.TOP, ToastGravity gravity = ToastGravity.TOP,
}) { }) {
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
final fToast = FToast(); final fToast = FToast();
fToast.init(context); fToast.init(context);
@@ -49,7 +50,7 @@ class ImmichToast {
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0), padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5.0), borderRadius: BorderRadius.circular(5.0),
color: Colors.grey[50], color: isDarkTheme ? Colors.grey[900] : Colors.grey[50],
border: Border.all( border: Border.all(
color: Colors.black12, color: Colors.black12,
width: 1, width: 1,

View File

@@ -115,4 +115,3 @@ lib/model/user_count_response_dto.dart
lib/model/user_response_dto.dart lib/model/user_response_dto.dart
lib/model/validate_access_token_response_dto.dart lib/model/validate_access_token_response_dto.dart
pubspec.yaml pubspec.yaml
test/asset_count_by_user_id_response_dto_test.dart

View File

@@ -76,69 +76,72 @@ class AssetResponseDto {
SmartInfoResponseDto? smartInfo; SmartInfoResponseDto? smartInfo;
@override @override
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto && bool operator ==(Object other) =>
other.type == type && identical(this, other) ||
other.id == id && other is AssetResponseDto &&
other.deviceAssetId == deviceAssetId && other.type == type &&
other.ownerId == ownerId && other.id == id &&
other.deviceId == deviceId && other.deviceAssetId == deviceAssetId &&
other.originalPath == originalPath && other.ownerId == ownerId &&
other.resizePath == resizePath && other.deviceId == deviceId &&
other.createdAt == createdAt && other.originalPath == originalPath &&
other.modifiedAt == modifiedAt && other.resizePath == resizePath &&
other.isFavorite == isFavorite && other.createdAt == createdAt &&
other.mimeType == mimeType && other.modifiedAt == modifiedAt &&
other.duration == duration && other.isFavorite == isFavorite &&
other.webpPath == webpPath && other.mimeType == mimeType &&
other.encodedVideoPath == encodedVideoPath && other.duration == duration &&
other.exifInfo == exifInfo && other.webpPath == webpPath &&
other.smartInfo == smartInfo; other.encodedVideoPath == encodedVideoPath &&
other.exifInfo == exifInfo &&
other.smartInfo == smartInfo;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(type.hashCode) + (type.hashCode) +
(id.hashCode) + (id.hashCode) +
(deviceAssetId.hashCode) + (deviceAssetId.hashCode) +
(ownerId.hashCode) + (ownerId.hashCode) +
(deviceId.hashCode) + (deviceId.hashCode) +
(originalPath.hashCode) + (originalPath.hashCode) +
(resizePath == null ? 0 : resizePath!.hashCode) + (resizePath == null ? 0 : resizePath!.hashCode) +
(createdAt.hashCode) + (createdAt.hashCode) +
(modifiedAt.hashCode) + (modifiedAt.hashCode) +
(isFavorite.hashCode) + (isFavorite.hashCode) +
(mimeType == null ? 0 : mimeType!.hashCode) + (mimeType == null ? 0 : mimeType!.hashCode) +
(duration.hashCode) + (duration.hashCode) +
(webpPath == null ? 0 : webpPath!.hashCode) + (webpPath == null ? 0 : webpPath!.hashCode) +
(encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) + (encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
(exifInfo == null ? 0 : exifInfo!.hashCode) + (exifInfo == null ? 0 : exifInfo!.hashCode) +
(smartInfo == null ? 0 : smartInfo!.hashCode); (smartInfo == null ? 0 : smartInfo!.hashCode);
@override @override
String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo]'; String toString() =>
'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final _json = <String, dynamic>{}; final _json = <String, dynamic>{};
_json[r'type'] = type; _json[r'type'] = type;
_json[r'id'] = id; _json[r'id'] = id;
_json[r'deviceAssetId'] = deviceAssetId; _json[r'deviceAssetId'] = deviceAssetId;
_json[r'ownerId'] = ownerId; _json[r'ownerId'] = ownerId;
_json[r'deviceId'] = deviceId; _json[r'deviceId'] = deviceId;
_json[r'originalPath'] = originalPath; _json[r'originalPath'] = originalPath;
if (resizePath != null) { if (resizePath != null) {
_json[r'resizePath'] = resizePath; _json[r'resizePath'] = resizePath;
} else { } else {
_json[r'resizePath'] = null; _json[r'resizePath'] = null;
} }
_json[r'createdAt'] = createdAt; _json[r'createdAt'] = createdAt;
_json[r'modifiedAt'] = modifiedAt; _json[r'modifiedAt'] = modifiedAt;
_json[r'isFavorite'] = isFavorite; _json[r'isFavorite'] = isFavorite;
if (mimeType != null) { if (mimeType != null) {
_json[r'mimeType'] = mimeType; _json[r'mimeType'] = mimeType;
} else { } else {
_json[r'mimeType'] = null; _json[r'mimeType'] = null;
} }
_json[r'duration'] = duration; _json[r'duration'] = duration;
if (webpPath != null) { if (webpPath != null) {
_json[r'webpPath'] = webpPath; _json[r'webpPath'] = webpPath;
} else { } else {
@@ -172,13 +175,13 @@ class AssetResponseDto {
// Ensure that the map contains the required keys. // Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null. // Note 1: the values aren't checked for validity beyond being non-null.
// Note 2: this code is stripped in release mode! // Note 2: this code is stripped in release mode!
assert(() { // assert(() {
requiredKeys.forEach((key) { // requiredKeys.forEach((key) {
assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.'); // assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.'); // assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
}); // });
return true; // return true;
}()); // }());
return AssetResponseDto( return AssetResponseDto(
type: AssetTypeEnum.fromJson(json[r'type'])!, type: AssetTypeEnum.fromJson(json[r'type'])!,
@@ -202,7 +205,10 @@ class AssetResponseDto {
return null; return null;
} }
static List<AssetResponseDto>? listFromJson(dynamic json, {bool growable = false,}) { static List<AssetResponseDto>? listFromJson(
dynamic json, {
bool growable = false,
}) {
final result = <AssetResponseDto>[]; final result = <AssetResponseDto>[];
if (json is List && json.isNotEmpty) { if (json is List && json.isNotEmpty) {
for (final row in json) { for (final row in json) {
@@ -230,12 +236,18 @@ class AssetResponseDto {
} }
// maps a json object with a list of AssetResponseDto-objects as value to a dart map // maps a json object with a list of AssetResponseDto-objects as value to a dart map
static Map<String, List<AssetResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) { static Map<String, List<AssetResponseDto>> mapListFromJson(
dynamic json, {
bool growable = false,
}) {
final map = <String, List<AssetResponseDto>>{}; final map = <String, List<AssetResponseDto>>{};
if (json is Map && json.isNotEmpty) { if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) { for (final entry in json.entries) {
final value = AssetResponseDto.listFromJson(entry.value, growable: growable,); final value = AssetResponseDto.listFromJson(
entry.value,
growable: growable,
);
if (value != null) { if (value != null) {
map[entry.key] = value; map[entry.key] = value;
} }
@@ -262,4 +274,3 @@ class AssetResponseDto {
'encodedVideoPath', 'encodedVideoPath',
}; };
} }

View File

@@ -7,14 +7,14 @@ packages:
name: _fe_analyzer_shared name: _fe_analyzer_shared
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "38.0.0" version: "47.0.0"
analyzer: analyzer:
dependency: transitive dependency: transitive
description: description:
name: analyzer name: analyzer
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.4.1" version: "4.7.0"
archive: archive:
dependency: transitive dependency: transitive
description: description:
@@ -42,14 +42,14 @@ packages:
name: auto_route name: auto_route
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.0.1" version: "5.0.1"
auto_route_generator: auto_route_generator:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: auto_route_generator name: auto_route_generator
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.0.0" version: "5.0.2"
badges: badges:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -77,7 +77,7 @@ packages:
name: build_config name: build_config
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.0" version: "1.1.0"
build_daemon: build_daemon:
dependency: transitive dependency: transitive
description: description:
@@ -98,7 +98,7 @@ packages:
name: build_runner name: build_runner
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.10" version: "2.2.1"
build_runner_core: build_runner_core:
dependency: transitive dependency: transitive
description: description:
@@ -126,21 +126,21 @@ packages:
name: cached_network_image name: cached_network_image
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.2.1" version: "3.2.2"
cached_network_image_platform_interface: cached_network_image_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: cached_network_image_platform_interface name: cached_network_image_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.0" version: "2.0.0"
cached_network_image_web: cached_network_image_web:
dependency: transitive dependency: transitive
description: description:
name: cached_network_image_web name: cached_network_image_web
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.1" version: "1.0.2"
cancellation_token: cancellation_token:
dependency: transitive dependency: transitive
description: description:
@@ -162,13 +162,6 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.2.1" version: "1.2.1"
charcode:
dependency: transitive
description:
name: charcode
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.1"
checked_yaml: checked_yaml:
dependency: transitive dependency: transitive
description: description:
@@ -182,7 +175,7 @@ packages:
name: chewie name: chewie
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.3.2" version: "1.3.5"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@@ -238,7 +231,7 @@ packages:
name: cupertino_icons name: cupertino_icons
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.4" version: "1.0.5"
dart_style: dart_style:
dependency: transitive dependency: transitive
description: description:
@@ -322,7 +315,7 @@ packages:
source: hosted source: hosted
version: "0.6.8" version: "0.6.8"
flutter_cache_manager: flutter_cache_manager:
dependency: "direct main" dependency: transitive
description: description:
name: flutter_cache_manager name: flutter_cache_manager
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
@@ -461,7 +454,7 @@ packages:
name: hive_generator name: hive_generator
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.1.2" version: "1.1.3"
hooks_riverpod: hooks_riverpod:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -503,7 +496,7 @@ packages:
name: image name: image
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.1.3" version: "3.2.0"
image_picker: image_picker:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -783,7 +776,7 @@ packages:
name: photo_manager name: photo_manager
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.0+2" version: "2.2.1"
photo_view: photo_view:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -839,7 +832,7 @@ packages:
name: provider name: provider
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.0.2" version: "6.0.3"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:
@@ -875,6 +868,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.27.3" version: "0.27.3"
scrollable_positioned_list:
dependency: "direct main"
description:
name: scrollable_positioned_list
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.4"
share_plus: share_plus:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1139,20 +1139,6 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.3.1" version: "0.3.1"
universal_html:
dependency: transitive
description:
name: universal_html
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.8"
universal_io:
dependency: transitive
description:
name: universal_io
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.4"
url_launcher: url_launcher:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1223,27 +1209,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
very_good_analysis:
dependency: transitive
description:
name: very_good_analysis
url: "https://pub.dartlang.org"
source: hosted
version: "2.4.0"
video_player: video_player:
dependency: "direct main" dependency: "direct main"
description: description:
name: video_player name: video_player
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.4.2" version: "2.4.7"
video_player_android: video_player_android:
dependency: transitive dependency: transitive
description: description:
name: video_player_android name: video_player_android
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.3.3" version: "2.3.9"
video_player_avfoundation: video_player_avfoundation:
dependency: transitive dependency: transitive
description: description:
@@ -1271,7 +1250,7 @@ packages:
name: wakelock name: wakelock
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.6.1+2" version: "0.6.2"
wakelock_macos: wakelock_macos:
dependency: transitive dependency: transitive
description: description:
@@ -1341,7 +1320,7 @@ packages:
name: xml name: xml
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "5.4.1" version: "6.1.0"
yaml: yaml:
dependency: transitive dependency: transitive
description: description:
@@ -1350,5 +1329,5 @@ packages:
source: hosted source: hosted
version: "3.1.1" version: "3.1.1"
sdks: sdks:
dart: ">=2.17.0 <3.0.0" dart: ">=2.18.0 <3.0.0"
flutter: ">=3.0.0" flutter: ">=3.3.0"

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: "none" publish_to: "none"
version: 1.28.0+38 version: 1.30.2+48
environment: environment:
sdk: ">=2.17.0 <3.0.0" sdk: ">=2.17.0 <3.0.0"
@@ -11,22 +11,22 @@ dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
photo_manager: ^2.0.6 photo_manager: ^2.2.1
flutter_hooks: ^0.18.0 flutter_hooks: ^0.18.0
hooks_riverpod: ^2.0.0-dev.0 hooks_riverpod: ^2.0.0-dev.0
hive: ^2.2.1 hive: ^2.2.1
hive_flutter: ^1.1.0 hive_flutter: ^1.1.0
dio: ^4.0.4 dio: ^4.0.4
cached_network_image: ^3.2.1 cached_network_image: ^3.2.2
percent_indicator: ^4.2.2 percent_indicator: ^4.2.2
intl: ^0.17.0 intl: ^0.17.0
auto_route: ^4.0.1 auto_route: ^5.0.1
exif: ^3.1.1 exif: ^3.1.1
transparent_image: ^2.0.0 transparent_image: ^2.0.0
flutter_launcher_icons: "^0.9.2" flutter_launcher_icons: "^0.9.2"
fluttertoast: ^8.0.8 fluttertoast: ^8.0.8
video_player: ^2.2.18 video_player: ^2.2.18
chewie: ^1.2.2 chewie: ^1.3.5
badges: ^2.0.2 badges: ^2.0.2
photo_view: ^0.14.0 photo_view: ^0.14.0
socket_io_client: ^2.0.0-beta.4-nullsafety.0 socket_io_client: ^2.0.0-beta.4-nullsafety.0
@@ -43,7 +43,7 @@ dependencies:
easy_localization: ^3.0.1 easy_localization: ^3.0.1
share_plus: ^4.0.10 share_plus: ^4.0.10
flutter_displaymode: ^0.4.0 flutter_displaymode: ^0.4.0
flutter_cache_manager: 3.3.0 scrollable_positioned_list: ^0.3.4
path: ^1.8.1 path: ^1.8.1
path_provider: ^2.0.11 path_provider: ^2.0.11
@@ -59,8 +59,8 @@ dev_dependencies:
sdk: flutter sdk: flutter
flutter_lints: ^2.0.1 flutter_lints: ^2.0.1
hive_generator: ^1.1.2 hive_generator: ^1.1.2
build_runner: ^2.1.7 build_runner: ^2.2.1
auto_route_generator: ^4.0.0 auto_route_generator: ^5.0.2
flutter: flutter:
uses-material-design: true uses-material-design: true

View File

@@ -1,6 +1,5 @@
FROM nginx:latest FROM docker.io/nginxinc/nginx-unprivileged:latest
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf "/etc/nginx/nginx.conf"
EXPOSE 80 CMD nginx -g "daemon off;"
EXPOSE 443

View File

@@ -1,73 +1,93 @@
map $http_upgrade $connection_upgrade {
default upgrade; worker_processes auto;
'' close; error_log /var/log/nginx/error.log;
pid /tmp/nginx.pid;
# Load dynamic modules. See /usr/share/doc/nginx/README.dynamic.
include /usr/share/nginx/modules/*.conf;
events {
worker_connections 1024;
} }
# events { http {
# worker_connections 1000; map $http_upgrade $connection_upgrade {
# } default upgrade;
'' close;
server {
gzip on;
gzip_min_length 1000;
gunzip on;
client_max_body_size 50000M;
listen 80;
access_log off;
location /api {
# Compression
gzip_static on;
gzip_min_length 1000;
gzip_comp_level 2;
proxy_buffering off;
proxy_buffer_size 16k;
proxy_busy_buffers_size 24k;
proxy_buffers 64 4k;
proxy_force_ranges on;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
rewrite /api/(.*) /$1 break;
proxy_pass http://immich-server:3001;
} }
location / { client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp_path;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;
# Compression # events {
gzip_static on; # worker_connections 1000;
# }
server {
gzip on;
gzip_min_length 1000; gzip_min_length 1000;
gzip_comp_level 2; gunzip on;
proxy_buffering off; client_max_body_size 50000M;
proxy_buffer_size 16k;
proxy_busy_buffers_size 24k;
proxy_buffers 64 4k;
proxy_force_ranges on;
proxy_http_version 1.1; listen 8080;
proxy_set_header Host $host; access_log off;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_pass http://immich-web:3000; location /api {
# Compression
gzip_static on;
gzip_min_length 1000;
gzip_comp_level 2;
proxy_buffering off;
proxy_buffer_size 16k;
proxy_busy_buffers_size 24k;
proxy_buffers 64 4k;
proxy_force_ranges on;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
rewrite /api/(.*) /$1 break;
proxy_pass http://immich-server:3001;
}
location / {
# Compression
gzip_static on;
gzip_min_length 1000;
gzip_comp_level 2;
proxy_buffering off;
proxy_buffer_size 16k;
proxy_busy_buffers_size 24k;
proxy_buffers 64 4k;
proxy_force_ranges on;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_pass http://immich-web:3000;
}
} }
} }

1
server/.gitignore vendored
View File

@@ -39,3 +39,4 @@ dist/
upload/ upload/
tmp/ tmp/
core core
.reverse-geocoding-dump/

View File

@@ -29,4 +29,6 @@ COPY --from=builder /usr/src/app/dist ./dist
RUN npm prune --production RUN npm prune --production
VOLUME /usr/src/app/upload
EXPOSE 3001 EXPOSE 3001

View File

@@ -11,7 +11,6 @@ import { GetAlbumsDto } from './dto/get-albums.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto'; import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { UpdateAlbumDto } from './dto/update-album.dto'; import { UpdateAlbumDto } from './dto/update-album.dto';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto'; import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
import { AlbumResponseDto } from './response-dto/album-response.dto';
export interface IAlbumRepository { export interface IAlbumRepository {
create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity>; create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity>;
@@ -51,9 +50,14 @@ export class AlbumRepository implements IAlbumRepository {
where: { sharedUserId: userId }, where: { sharedUserId: userId },
}); });
const sharingAlbums = ownedAlbums.map((album) => album.sharedUsers?.length || 0).reduce((a, b) => a + b, 0); let sharedAlbumCount = 0;
ownedAlbums.map((album) => {
if (album.sharedUsers?.length) {
sharedAlbumCount += 1;
}
});
return new AlbumCountResponseDto(ownedAlbums.length, sharedAlbums, sharingAlbums); return new AlbumCountResponseDto(ownedAlbums.length, sharedAlbums, sharedAlbumCount);
} }
async create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity> { async create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity> {
@@ -165,7 +169,7 @@ export class AlbumRepository implements IAlbumRepository {
} }
async getListByAssetId(userId: string, assetId: string): Promise<AlbumEntity[]> { async getListByAssetId(userId: string, assetId: string): Promise<AlbumEntity[]> {
let query = this.albumRepository.createQueryBuilder('album'); const query = this.albumRepository.createQueryBuilder('album');
const albums = await query const albums = await query
.where('album.ownerId = :ownerId', { ownerId: userId }) .where('album.ownerId = :ownerId', { ownerId: userId })
@@ -190,7 +194,7 @@ export class AlbumRepository implements IAlbumRepository {
} }
async get(albumId: string): Promise<AlbumEntity | undefined> { async get(albumId: string): Promise<AlbumEntity | undefined> {
let query = this.albumRepository.createQueryBuilder('album'); const query = this.albumRepository.createQueryBuilder('album');
const album = await query const album = await query
.where('album.id = :albumId', { albumId }) .where('album.id = :albumId', { albumId })

View File

@@ -11,7 +11,6 @@ import {
ParseUUIDPipe, ParseUUIDPipe,
Put, Put,
Query, Query,
Header,
} from '@nestjs/common'; } from '@nestjs/common';
import { ParseMeUUIDPipe } from '../validation/parse-me-uuid-pipe'; import { ParseMeUUIDPipe } from '../validation/parse-me-uuid-pipe';
import { AlbumService } from './album.service'; import { AlbumService } from './album.service';

View File

@@ -8,6 +8,7 @@ import { AlbumEntity } from '../../../../../libs/database/src/entities/album.ent
import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity'; import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
import { UserAlbumEntity } from '@app/database/entities/user-album.entity'; import { UserAlbumEntity } from '@app/database/entities/user-album.entity';
import { AlbumRepository, ALBUM_REPOSITORY } from './album-repository'; import { AlbumRepository, ALBUM_REPOSITORY } from './album-repository';
import { AssetRepository, ASSET_REPOSITORY } from '../asset/asset-repository';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([AssetEntity, UserEntity, AlbumEntity, AssetAlbumEntity, UserAlbumEntity])], imports: [TypeOrmModule.forFeature([AssetEntity, UserEntity, AlbumEntity, AssetAlbumEntity, UserAlbumEntity])],
@@ -18,6 +19,10 @@ import { AlbumRepository, ALBUM_REPOSITORY } from './album-repository';
provide: ALBUM_REPOSITORY, provide: ALBUM_REPOSITORY,
useClass: AlbumRepository, useClass: AlbumRepository,
}, },
{
provide: ASSET_REPOSITORY,
useClass: AssetRepository,
},
], ],
}) })
export class AlbumModule {} export class AlbumModule {}

View File

@@ -4,11 +4,13 @@ import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
import { AlbumEntity } from '@app/database/entities/album.entity'; import { AlbumEntity } from '@app/database/entities/album.entity';
import { AlbumResponseDto } from './response-dto/album-response.dto'; import { AlbumResponseDto } from './response-dto/album-response.dto';
import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity'; import { IAssetRepository } from '../asset/asset-repository';
describe('Album service', () => { describe('Album service', () => {
let sut: AlbumService; let sut: AlbumService;
let albumRepositoryMock: jest.Mocked<IAlbumRepository>; let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
const authUser: AuthUserDto = Object.freeze({ const authUser: AuthUserDto = Object.freeze({
id: '1111', id: '1111',
email: 'auth@test.com', email: 'auth@test.com',
@@ -119,7 +121,22 @@ describe('Album service', () => {
getListByAssetId: jest.fn(), getListByAssetId: jest.fn(),
getCountByUserId: jest.fn(), getCountByUserId: jest.fn(),
}; };
sut = new AlbumService(albumRepositoryMock);
assetRepositoryMock = {
create: jest.fn(),
getAllByUserId: jest.fn(),
getAllByDeviceId: jest.fn(),
getAssetCountByTimeBucket: jest.fn(),
getById: jest.fn(),
getDetectedObjectsByUserId: jest.fn(),
getLocationsByUserId: jest.fn(),
getSearchPropertiesByUserId: jest.fn(),
getAssetByTimeBucket: jest.fn(),
getAssetByChecksum: jest.fn(),
getAssetCountByUserId: jest.fn(),
};
sut = new AlbumService(albumRepositoryMock, assetRepositoryMock);
}); });
it('creates album', async () => { it('creates album', async () => {

View File

@@ -10,10 +10,14 @@ import { GetAlbumsDto } from './dto/get-albums.dto';
import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from './response-dto/album-response.dto'; import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from './response-dto/album-response.dto';
import { ALBUM_REPOSITORY, IAlbumRepository } from './album-repository'; import { ALBUM_REPOSITORY, IAlbumRepository } from './album-repository';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto'; import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
import { ASSET_REPOSITORY, IAssetRepository } from '../asset/asset-repository';
@Injectable() @Injectable()
export class AlbumService { export class AlbumService {
constructor(@Inject(ALBUM_REPOSITORY) private _albumRepository: IAlbumRepository) {} constructor(
@Inject(ALBUM_REPOSITORY) private _albumRepository: IAlbumRepository,
@Inject(ASSET_REPOSITORY) private _assetRepository: IAssetRepository,
) {}
private async _getAlbum({ private async _getAlbum({
authUser, authUser,
@@ -54,6 +58,11 @@ export class AlbumService {
return albums.map(mapAlbumExcludeAssetInfo); return albums.map(mapAlbumExcludeAssetInfo);
} }
const albums = await this._albumRepository.getList(authUser.id, getAlbumsDto); const albums = await this._albumRepository.getList(authUser.id, getAlbumsDto);
for (const album of albums) {
await this._checkValidThumbnail(album);
}
return albums.map((album) => mapAlbumExcludeAssetInfo(album)); return albums.map((album) => mapAlbumExcludeAssetInfo(album));
} }
@@ -123,4 +132,18 @@ export class AlbumService {
async getAlbumCountByUserId(authUser: AuthUserDto): Promise<AlbumCountResponseDto> { async getAlbumCountByUserId(authUser: AuthUserDto): Promise<AlbumCountResponseDto> {
return this._albumRepository.getCountByUserId(authUser.id); return this._albumRepository.getCountByUserId(authUser.id);
} }
async _checkValidThumbnail(album: AlbumEntity): Promise<AlbumEntity> {
const assetId = album.albumThumbnailAssetId;
if (assetId) {
try {
await this._assetRepository.getById(assetId);
} catch (e) {
album.albumThumbnailAssetId = null;
return await this._albumRepository.updateAlbum(album, {});
}
}
return album;
}
} }

View File

@@ -1,4 +1,4 @@
import { IsNotEmpty, IsOptional } from 'class-validator'; import { IsOptional } from 'class-validator';
export class UpdateAlbumDto { export class UpdateAlbumDto {
@IsOptional() @IsOptional()

View File

@@ -15,7 +15,6 @@ import {
HttpCode, HttpCode,
BadRequestException, BadRequestException,
UploadedFile, UploadedFile,
Header,
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { AssetService } from './asset.service'; import { AssetService } from './asset.service';
@@ -34,7 +33,7 @@ import { IAssetUploadedJob } from '@app/job/index';
import { assetUploadedQueueName } from '@app/job/constants/queue-name.constant'; import { assetUploadedQueueName } from '@app/job/constants/queue-name.constant';
import { assetUploadedProcessorName } from '@app/job/constants/job-name.constant'; import { assetUploadedProcessorName } from '@app/job/constants/job-name.constant';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto'; import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto'; import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto'; import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { AssetResponseDto } from './response-dto/asset-response.dto'; import { AssetResponseDto } from './response-dto/asset-response.dto';
@@ -98,7 +97,7 @@ export class AssetController {
await this.assetUploadedQueue.add( await this.assetUploadedQueue.add(
assetUploadedProcessorName, assetUploadedProcessorName,
{ asset: savedAsset, fileName: file.originalname, fileSize: file.size }, { asset: savedAsset, fileName: file.originalname },
{ jobId: savedAsset.id }, { jobId: savedAsset.id },
); );

View File

@@ -1,9 +1,12 @@
import { AssetRepository, IAssetRepository } from './asset-repository'; import { IAssetRepository } from './asset-repository';
import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { AssetService } from './asset.service'; import { AssetService } from './asset.service';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity'; import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
import { CreateAssetDto } from './dto/create-asset.dto'; import { CreateAssetDto } from './dto/create-asset.dto';
import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
describe('AssetService', () => { describe('AssetService', () => {
let sui: AssetService; let sui: AssetService;
@@ -11,7 +14,7 @@ describe('AssetService', () => {
let assetRepositoryMock: jest.Mocked<IAssetRepository>; let assetRepositoryMock: jest.Mocked<IAssetRepository>;
const authUser: AuthUserDto = Object.freeze({ const authUser: AuthUserDto = Object.freeze({
id: '3ea54709-e168-42b7-90b0-a0dfe8a7ecbd', id: 'user_id_1',
email: 'auth@test.com', email: 'auth@test.com',
}); });
@@ -27,26 +30,68 @@ describe('AssetService', () => {
return createAssetDto; return createAssetDto;
}; };
const _getAsset = () => {
const assetEntity = new AssetEntity();
assetEntity.id = 'e8edabfd-7d8a-45d0-9d61-7c7ca60f2c67'; const _getAsset_1 = () => {
assetEntity.userId = '3ea54709-e168-42b7-90b0-a0dfe8a7ecbd'; const asset_1 = new AssetEntity();
assetEntity.deviceAssetId = '4967046344801';
assetEntity.deviceId = '116766fd-2ef2-52dc-a3ef-149988997291';
assetEntity.type = AssetType.VIDEO;
assetEntity.originalPath =
'upload/3ea54709-e168-42b7-90b0-a0dfe8a7ecbd/original/116766fd-2ef2-52dc-a3ef-149988997291/51c97f95-244f-462d-bdf0-e1dc19913516.jpg';
assetEntity.resizePath = '';
assetEntity.createdAt = '2022-06-19T23:41:36.910Z';
assetEntity.modifiedAt = '2022-06-19T23:41:36.910Z';
assetEntity.isFavorite = false;
assetEntity.mimeType = 'image/jpeg';
assetEntity.webpPath = '';
assetEntity.encodedVideoPath = '';
assetEntity.duration = '0:00:00.000000';
return assetEntity; asset_1.id = 'id_1';
asset_1.userId = 'user_id_1';
asset_1.deviceAssetId = 'device_asset_id_1';
asset_1.deviceId = 'device_id_1';
asset_1.type = AssetType.VIDEO;
asset_1.originalPath = 'fake_path/asset_1.jpeg';
asset_1.resizePath = '';
asset_1.createdAt = '2022-06-19T23:41:36.910Z';
asset_1.modifiedAt = '2022-06-19T23:41:36.910Z';
asset_1.isFavorite = false;
asset_1.mimeType = 'image/jpeg';
asset_1.webpPath = '';
asset_1.encodedVideoPath = '';
asset_1.duration = '0:00:00.000000';
return asset_1;
};
const _getAsset_2 = () => {
const asset_2 = new AssetEntity();
asset_2.id = 'id_2';
asset_2.userId = 'user_id_1';
asset_2.deviceAssetId = 'device_asset_id_2';
asset_2.deviceId = 'device_id_1';
asset_2.type = AssetType.VIDEO;
asset_2.originalPath = 'fake_path/asset_2.jpeg';
asset_2.resizePath = '';
asset_2.createdAt = '2022-06-19T23:41:36.910Z';
asset_2.modifiedAt = '2022-06-19T23:41:36.910Z';
asset_2.isFavorite = false;
asset_2.mimeType = 'image/jpeg';
asset_2.webpPath = '';
asset_2.encodedVideoPath = '';
asset_2.duration = '0:00:00.000000';
return asset_2;
};
const _getAssets = () => {
return [_getAsset_1(), _getAsset_2()];
};
const _getAssetCountByTimeBucket = (): AssetCountByTimeBucket[] => {
const result1 = new AssetCountByTimeBucket();
result1.count = 2;
result1.timeBucket = '2022-06-01T00:00:00.000Z';
const result2 = new AssetCountByTimeBucket();
result1.count = 5;
result1.timeBucket = '2022-07-01T00:00:00.000Z';
return [result1, result2];
};
const _getAssetCountByUserId = (): AssetCountByUserIdResponseDto => {
const result = new AssetCountByUserIdResponseDto(2, 2);
return result;
}; };
beforeAll(() => { beforeAll(() => {
@@ -68,29 +113,65 @@ describe('AssetService', () => {
}); });
// Currently failing due to calculate checksum from a file // Currently failing due to calculate checksum from a file
// it('create an asset', async () => { it('create an asset', async () => {
// const assetEntity = _getAsset(); const assetEntity = _getAsset_1();
// assetRepositoryMock.create.mockImplementation(() => Promise.resolve<AssetEntity>(assetEntity)); assetRepositoryMock.create.mockImplementation(() => Promise.resolve<AssetEntity>(assetEntity));
// const originalPath = const originalPath = 'fake_path/asset_1.jpeg';
// 'upload/3ea54709-e168-42b7-90b0-a0dfe8a7ecbd/original/116766fd-2ef2-52dc-a3ef-149988997291/51c97f95-244f-462d-bdf0-e1dc19913516.jpg'; const mimeType = 'image/jpeg';
// const mimeType = 'image/jpeg'; const createAssetDto = _getCreateAssetDto();
// const createAssetDto = _getCreateAssetDto(); const result = await sui.createUserAsset(
// const result = await sui.createUserAsset(authUser, createAssetDto, originalPath, mimeType); authUser,
createAssetDto,
originalPath,
mimeType,
Buffer.from('0x5041E6328F7DF8AFF650BEDAED9251897D9A6241', 'hex'),
);
// expect(result.userId).toEqual(authUser.id); expect(result.userId).toEqual(authUser.id);
// expect(result.resizePath).toEqual(''); expect(result.resizePath).toEqual('');
// expect(result.webpPath).toEqual(''); expect(result.webpPath).toEqual('');
// }); });
it('get assets by device id', async () => { it('get assets by device id', async () => {
assetRepositoryMock.getAllByDeviceId.mockImplementation(() => Promise.resolve<string[]>(['4967046344801'])); const assets = _getAssets();
const deviceId = '116766fd-2ef2-52dc-a3ef-149988997291'; assetRepositoryMock.getAllByDeviceId.mockImplementation(() =>
Promise.resolve<string[]>(Array.from(assets.map((asset) => asset.deviceAssetId))),
);
const deviceId = 'device_id_1';
const result = await sui.getUserAssetsByDeviceId(authUser, deviceId); const result = await sui.getUserAssetsByDeviceId(authUser, deviceId);
expect(result.length).toEqual(1); expect(result.length).toEqual(2);
expect(result[0]).toEqual('4967046344801'); expect(result).toEqual(assets.map((asset) => asset.deviceAssetId));
});
it('get assets count by time bucket', async () => {
const assetCountByTimeBucket = _getAssetCountByTimeBucket();
assetRepositoryMock.getAssetCountByTimeBucket.mockImplementation(() =>
Promise.resolve<AssetCountByTimeBucket[]>(assetCountByTimeBucket),
);
const result = await sui.getAssetCountByTimeBucket(authUser, {
timeGroup: TimeGroupEnum.Month,
});
expect(result.totalCount).toEqual(assetCountByTimeBucket.reduce((a, b) => a + b.count, 0));
expect(result.buckets.length).toEqual(2);
});
it('get asset count by user id', async () => {
const assetCount = _getAssetCountByUserId();
assetRepositoryMock.getAssetCountByUserId.mockImplementation(() =>
Promise.resolve<AssetCountByUserIdResponseDto>(assetCount),
);
const result = await sui.getAssetCountByUserId(authUser);
expect(result).toEqual(assetCount);
}); });
}); });

View File

@@ -36,6 +36,7 @@ import {
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto'; import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto'; import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto'; import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
import { timeUtils } from '@app/common/utils';
const fileInfo = promisify(stat); const fileInfo = promisify(stat);
@@ -56,6 +57,18 @@ export class AssetService {
mimeType: string, mimeType: string,
checksum: Buffer, checksum: Buffer,
): Promise<AssetEntity> { ): Promise<AssetEntity> {
// Check valid time.
const createdAt = createAssetDto.createdAt;
const modifiedAt = createAssetDto.modifiedAt;
if (!timeUtils.checkValidTimestamp(createdAt)) {
createAssetDto.createdAt = await timeUtils.getTimestampFromExif(originalPath);
}
if (!timeUtils.checkValidTimestamp(modifiedAt)) {
createAssetDto.modifiedAt = await timeUtils.getTimestampFromExif(originalPath);
}
const assetEntity = await this._assetRepository.create( const assetEntity = await this._assetRepository.create(
createAssetDto, createAssetDto,
authUser.id, authUser.id,

View File

@@ -1,6 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { IsOptional } from 'class-validator';
import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
export enum GetAssetThumbnailFormatEnum { export enum GetAssetThumbnailFormatEnum {
JPEG = 'JPEG', JPEG = 'JPEG',

View File

@@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsBoolean, IsBooleanString, IsNotEmpty, IsOptional } from 'class-validator'; import { IsBoolean, IsNotEmpty, IsOptional } from 'class-validator';
export class ServeFileDto { export class ServeFileDto {
@IsNotEmpty() @IsNotEmpty()

View File

@@ -1,12 +1,5 @@
import { Body, Controller, Post, Res, UseGuards, ValidationPipe } from '@nestjs/common'; import { Body, Controller, Post, Res, UseGuards, ValidationPipe } from '@nestjs/common';
import { import { ApiBadRequestResponse, ApiBearerAuth, ApiTags } from '@nestjs/swagger';
ApiBadRequestResponse,
ApiBearerAuth,
ApiBody,
ApiCreatedResponse,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';

View File

@@ -1,5 +1,5 @@
import { UserEntity } from '@app/database/entities/user.entity'; import { UserEntity } from '@app/database/entities/user.entity';
import { ApiProperty, ApiResponseProperty } from '@nestjs/swagger'; import { ApiResponseProperty } from '@nestjs/swagger';
export class LoginResponseDto { export class LoginResponseDto {
@ApiResponseProperty() @ApiResponseProperty()

View File

@@ -1,8 +1,6 @@
import { DeviceType } from '@app/database/entities/device-info.entity'; import { DeviceType } from '@app/database/entities/device-info.entity';
import { PartialType } from '@nestjs/mapped-types';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional } from 'class-validator'; import { IsNotEmpty, IsOptional } from 'class-validator';
import { CreateDeviceInfoDto } from './create-device-info.dto';
export class UpdateDeviceInfoDto { export class UpdateDeviceInfoDto {
@IsNotEmpty() @IsNotEmpty()

Some files were not shown because too many files have changed in this diff Show More