mirror of
https://github.com/immich-app/immich.git
synced 2025-01-01 08:31:59 +00:00
refactor(mobile): encapsulate most access to photomanager in repository (#12754)
* refactor(mobile): encapsulate most access to photomanager in repository
This commit is contained in:
parent
6740c67ed8
commit
6995cc2b38
45 changed files with 1205 additions and 500 deletions
4
.github/workflows/static_analysis.yml
vendored
4
.github/workflows/static_analysis.yml
vendored
|
@ -56,6 +56,10 @@ jobs:
|
||||||
run: dart format lib/ --set-exit-if-changed
|
run: dart format lib/ --set-exit-if-changed
|
||||||
working-directory: ./mobile
|
working-directory: ./mobile
|
||||||
|
|
||||||
|
- name: Run dart custom_lint
|
||||||
|
run: dart run custom_lint
|
||||||
|
working-directory: ./mobile
|
||||||
|
|
||||||
# Enable after riverpod generator migration is completed
|
# Enable after riverpod generator migration is completed
|
||||||
# - name: Run dart custom lint
|
# - name: Run dart custom lint
|
||||||
# run: dart run custom_lint
|
# run: dart run custom_lint
|
||||||
|
|
|
@ -36,9 +36,32 @@ analyzer:
|
||||||
- openapi/**
|
- openapi/**
|
||||||
- lib/generated_plugin_registrant.dart
|
- lib/generated_plugin_registrant.dart
|
||||||
|
|
||||||
plugins:
|
plugins:
|
||||||
- custom_lint
|
- custom_lint
|
||||||
|
|
||||||
|
custom_lint:
|
||||||
|
debug: true
|
||||||
|
rules:
|
||||||
|
- avoid_build_context_in_providers: false
|
||||||
|
- avoid_public_notifier_properties: false
|
||||||
|
- avoid_manual_providers_as_generated_provider_dependency: false
|
||||||
|
- unsupported_provider_value: false
|
||||||
|
- photo_manager:
|
||||||
|
exclude:
|
||||||
|
# required / wanted
|
||||||
|
- album_media.repository.dart
|
||||||
|
- asset_media.repository.dart
|
||||||
|
- file_media.repository.dart
|
||||||
|
# acceptable exceptions for the time being
|
||||||
|
- asset.entity.dart # to provide local AssetEntity for now
|
||||||
|
- immich_local_image_provider.dart # accesses thumbnails via PhotoManager
|
||||||
|
- immich_local_thumbnail_provider.dart # accesses thumbnails via PhotoManager
|
||||||
|
# refactor to make the providers and services testable
|
||||||
|
- backup.provider.dart # uses only PMProgressHandler
|
||||||
|
- manual_upload.provider.dart # uses only PMProgressHandler
|
||||||
|
- background.service.dart # uses only PMProgressHandler
|
||||||
|
- backup.service.dart # uses only PMProgressHandler
|
||||||
|
|
||||||
dart_code_metrics:
|
dart_code_metrics:
|
||||||
metrics:
|
metrics:
|
||||||
cyclomatic-complexity: 20
|
cyclomatic-complexity: 20
|
||||||
|
|
1
mobile/immich_lint/analysis_options.yaml
Normal file
1
mobile/immich_lint/analysis_options.yaml
Normal file
|
@ -0,0 +1 @@
|
||||||
|
include: package:lints/recommended.yaml
|
49
mobile/immich_lint/lib/immich_mobile_immich_lint.dart
Normal file
49
mobile/immich_lint/lib/immich_mobile_immich_lint.dart
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import 'package:analyzer/error/listener.dart';
|
||||||
|
import 'package:analyzer/error/error.dart' show ErrorSeverity;
|
||||||
|
import 'package:custom_lint_builder/custom_lint_builder.dart';
|
||||||
|
|
||||||
|
PluginBase createPlugin() => ImmichLinter();
|
||||||
|
|
||||||
|
class ImmichLinter extends PluginBase {
|
||||||
|
@override
|
||||||
|
List<LintRule> getLintRules(CustomLintConfigs configs) => [
|
||||||
|
PhotoManagerRule(configs.rules[PhotoManagerRule._code.name]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
class PhotoManagerRule extends DartLintRule {
|
||||||
|
PhotoManagerRule(LintOptions? options) : super(code: _code) {
|
||||||
|
final excludeOption = options?.json["exclude"];
|
||||||
|
if (excludeOption is String) {
|
||||||
|
_excludePaths.add(excludeOption);
|
||||||
|
} else if (excludeOption is List) {
|
||||||
|
_excludePaths.addAll(excludeOption.map((option) => option));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final Set<String> _excludePaths = HashSet();
|
||||||
|
|
||||||
|
static const _code = LintCode(
|
||||||
|
name: 'photo_manager',
|
||||||
|
problemMessage:
|
||||||
|
'photo_manager library must only be used in MediaRepository',
|
||||||
|
errorSeverity: ErrorSeverity.WARNING,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void run(
|
||||||
|
CustomLintResolver resolver,
|
||||||
|
ErrorReporter reporter,
|
||||||
|
CustomLintContext context,
|
||||||
|
) {
|
||||||
|
if (_excludePaths.contains(resolver.source.shortName)) return;
|
||||||
|
|
||||||
|
context.registry.addImportDirective((node) {
|
||||||
|
if (node.uri.stringValue?.startsWith("package:photo_manager") == true) {
|
||||||
|
reporter.atNode(node, code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
370
mobile/immich_lint/pubspec.lock
Normal file
370
mobile/immich_lint/pubspec.lock
Normal file
|
@ -0,0 +1,370 @@
|
||||||
|
# Generated by pub
|
||||||
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
|
packages:
|
||||||
|
_fe_analyzer_shared:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: _fe_analyzer_shared
|
||||||
|
sha256: "45cfa8471b89fb6643fe9bf51bd7931a76b8f5ec2d65de4fb176dba8d4f22c77"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "73.0.0"
|
||||||
|
_macros:
|
||||||
|
dependency: transitive
|
||||||
|
description: dart
|
||||||
|
source: sdk
|
||||||
|
version: "0.3.2"
|
||||||
|
analyzer:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: analyzer
|
||||||
|
sha256: "4959fec185fe70cce007c57e9ab6983101dbe593d2bf8bbfb4453aaec0cf470a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.8.0"
|
||||||
|
analyzer_plugin:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: analyzer_plugin
|
||||||
|
sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.11.3"
|
||||||
|
args:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: args
|
||||||
|
sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.0"
|
||||||
|
async:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: async
|
||||||
|
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.11.0"
|
||||||
|
boolean_selector:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: boolean_selector
|
||||||
|
sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.1"
|
||||||
|
checked_yaml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: checked_yaml
|
||||||
|
sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.3"
|
||||||
|
ci:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: ci
|
||||||
|
sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.0"
|
||||||
|
cli_util:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cli_util
|
||||||
|
sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.4.1"
|
||||||
|
collection:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: collection
|
||||||
|
sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.19.0"
|
||||||
|
convert:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: convert
|
||||||
|
sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.1"
|
||||||
|
crypto:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: crypto
|
||||||
|
sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.5"
|
||||||
|
custom_lint:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: custom_lint
|
||||||
|
sha256: "6e1ec47427ca968f22bce734d00028ae7084361999b41673291138945c5baca0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.6.7"
|
||||||
|
custom_lint_builder:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: custom_lint_builder
|
||||||
|
sha256: ba2f90fff4eff71d202d097eb14b14f87087eaaef742e956208c0eb9d3a40a21
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.6.7"
|
||||||
|
custom_lint_core:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: custom_lint_core
|
||||||
|
sha256: "4ddbbdaa774265de44c97054dcec058a83d9081d071785ece601e348c18c267d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.6.5"
|
||||||
|
dart_style:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dart_style
|
||||||
|
sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.7"
|
||||||
|
file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file
|
||||||
|
sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.0"
|
||||||
|
fixnum:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: fixnum
|
||||||
|
sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
|
freezed_annotation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: freezed_annotation
|
||||||
|
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.4"
|
||||||
|
glob:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: glob
|
||||||
|
sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
|
hotreloader:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: hotreloader
|
||||||
|
sha256: ed56fdc1f3a8ac924e717257621d09e9ec20e308ab6352a73a50a1d7a4d9158e
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.2.0"
|
||||||
|
json_annotation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: json_annotation
|
||||||
|
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.9.0"
|
||||||
|
lints:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: lints
|
||||||
|
sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.0"
|
||||||
|
logging:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: logging
|
||||||
|
sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.0"
|
||||||
|
macros:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: macros
|
||||||
|
sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.2-main.4"
|
||||||
|
matcher:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: matcher
|
||||||
|
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.12.16+1"
|
||||||
|
meta:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: meta
|
||||||
|
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.15.0"
|
||||||
|
package_config:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_config
|
||||||
|
sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.0"
|
||||||
|
path:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path
|
||||||
|
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.9.0"
|
||||||
|
pub_semver:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pub_semver
|
||||||
|
sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.4"
|
||||||
|
pubspec_parse:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pubspec_parse
|
||||||
|
sha256: c799b721d79eb6ee6fa56f00c04b472dcd44a30d258fac2174a6ec57302678f8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
|
rxdart:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: rxdart
|
||||||
|
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.28.0"
|
||||||
|
source_span:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_span
|
||||||
|
sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.10.0"
|
||||||
|
sprintf:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sprintf
|
||||||
|
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.0"
|
||||||
|
stack_trace:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stack_trace
|
||||||
|
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.11.1"
|
||||||
|
stream_channel:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stream_channel
|
||||||
|
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
|
stream_transform:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stream_transform
|
||||||
|
sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.0"
|
||||||
|
string_scanner:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: string_scanner
|
||||||
|
sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
|
term_glyph:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: term_glyph
|
||||||
|
sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.1"
|
||||||
|
test_api:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: test_api
|
||||||
|
sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.3"
|
||||||
|
typed_data:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: typed_data
|
||||||
|
sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.2"
|
||||||
|
uuid:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: uuid
|
||||||
|
sha256: f33d6bb662f0e4f79dcd7ada2e6170f3b3a2530c28fc41f49a411ddedd576a77
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.5.0"
|
||||||
|
vm_service:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vm_service
|
||||||
|
sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "14.2.5"
|
||||||
|
watcher:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: watcher
|
||||||
|
sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
|
yaml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: yaml
|
||||||
|
sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.2"
|
||||||
|
sdks:
|
||||||
|
dart: ">=3.4.0 <4.0.0"
|
13
mobile/immich_lint/pubspec.yaml
Normal file
13
mobile/immich_lint/pubspec.yaml
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
name: immich_mobile_immich_lint
|
||||||
|
publish_to: none
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
analyzer: ^6.8.0
|
||||||
|
analyzer_plugin: ^0.11.3
|
||||||
|
custom_lint_builder: ^0.6.4
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
lints: ^4.0.0
|
|
@ -1,11 +1,9 @@
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
|
||||||
import 'package:immich_mobile/entities/user.entity.dart';
|
import 'package:immich_mobile/entities/user.entity.dart';
|
||||||
import 'package:immich_mobile/utils/datetime_comparison.dart';
|
import 'package:immich_mobile/utils/datetime_comparison.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
|
||||||
|
|
||||||
part 'album.entity.g.dart';
|
part 'album.entity.g.dart';
|
||||||
|
|
||||||
|
@ -43,6 +41,9 @@ class Album {
|
||||||
final IsarLinks<User> sharedUsers = IsarLinks<User>();
|
final IsarLinks<User> sharedUsers = IsarLinks<User>();
|
||||||
final IsarLinks<Asset> assets = IsarLinks<Asset>();
|
final IsarLinks<Asset> assets = IsarLinks<Asset>();
|
||||||
|
|
||||||
|
@ignore
|
||||||
|
bool isAll = false;
|
||||||
|
|
||||||
@ignore
|
@ignore
|
||||||
bool get isRemote => remoteId != null;
|
bool get isRemote => remoteId != null;
|
||||||
|
|
||||||
|
@ -70,6 +71,9 @@ class Album {
|
||||||
return name.join(' ');
|
return name.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ignore
|
||||||
|
String get eTagKeyAssetCount => "device-album-$localId-asset-count";
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(other) {
|
bool operator ==(other) {
|
||||||
if (other is! Album) return false;
|
if (other is! Album) return false;
|
||||||
|
@ -112,19 +116,6 @@ class Album {
|
||||||
sharedUsers.length.hashCode ^
|
sharedUsers.length.hashCode ^
|
||||||
assets.length.hashCode;
|
assets.length.hashCode;
|
||||||
|
|
||||||
static Album local(AssetPathEntity ape) {
|
|
||||||
final Album a = Album(
|
|
||||||
name: ape.name,
|
|
||||||
createdAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
|
|
||||||
modifiedAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
|
|
||||||
shared: false,
|
|
||||||
activityEnabled: false,
|
|
||||||
);
|
|
||||||
a.owner.value = Store.get(StoreKey.currentUser);
|
|
||||||
a.localId = ape.id;
|
|
||||||
return a;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<Album> remote(AlbumResponseDto dto) async {
|
static Future<Album> remote(AlbumResponseDto dto) async {
|
||||||
final Isar db = Isar.getInstance()!;
|
final Isar db = Isar.getInstance()!;
|
||||||
final Album a = Album(
|
final Album a = Album(
|
||||||
|
@ -177,7 +168,3 @@ extension AssetsHelper on IsarCollection<Album> {
|
||||||
extension AlbumResponseDtoHelper on AlbumResponseDto {
|
extension AlbumResponseDtoHelper on AlbumResponseDto {
|
||||||
List<Asset> getAssets() => assets.map(Asset.remote).toList();
|
List<Asset> getAssets() => assets.map(Asset.remote).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AssetPathEntityHelper on AssetPathEntity {
|
|
||||||
String get eTagKeyAssetCount => "device-album-$id-asset-count";
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,11 +1,10 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
|
||||||
import 'package:immich_mobile/utils/hash.dart';
|
import 'package:immich_mobile/utils/hash.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart' show AssetEntity;
|
||||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
@ -42,33 +41,6 @@ class Asset {
|
||||||
stackId = remote.stack?.id,
|
stackId = remote.stack?.id,
|
||||||
thumbhash = remote.thumbhash;
|
thumbhash = remote.thumbhash;
|
||||||
|
|
||||||
Asset.local(AssetEntity local, List<int> hash)
|
|
||||||
: localId = local.id,
|
|
||||||
checksum = base64.encode(hash),
|
|
||||||
durationInSeconds = local.duration,
|
|
||||||
type = AssetType.values[local.typeInt],
|
|
||||||
height = local.height,
|
|
||||||
width = local.width,
|
|
||||||
fileName = local.title!,
|
|
||||||
ownerId = Store.get(StoreKey.currentUser).isarId,
|
|
||||||
fileModifiedAt = local.modifiedDateTime,
|
|
||||||
updatedAt = local.modifiedDateTime,
|
|
||||||
isFavorite = local.isFavorite,
|
|
||||||
isArchived = false,
|
|
||||||
isTrashed = false,
|
|
||||||
isOffline = false,
|
|
||||||
stackCount = 0,
|
|
||||||
fileCreatedAt = local.createDateTime {
|
|
||||||
if (fileCreatedAt.year == 1970) {
|
|
||||||
fileCreatedAt = fileModifiedAt;
|
|
||||||
}
|
|
||||||
if (local.latitude != null) {
|
|
||||||
exifInfo = ExifInfo(lat: local.latitude, long: local.longitude);
|
|
||||||
}
|
|
||||||
_local = local;
|
|
||||||
assert(hash.length == 20, "invalid SHA1 hash");
|
|
||||||
}
|
|
||||||
|
|
||||||
Asset({
|
Asset({
|
||||||
this.id = Isar.autoIncrement,
|
this.id = Isar.autoIncrement,
|
||||||
required this.checksum,
|
required this.checksum,
|
||||||
|
@ -115,6 +87,8 @@ class Asset {
|
||||||
return _local;
|
return _local;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
set local(AssetEntity? assetEntity) => _local = assetEntity;
|
||||||
|
|
||||||
Id id = Isar.autoIncrement;
|
Id id = Isar.autoIncrement;
|
||||||
|
|
||||||
/// stores the raw SHA1 bytes as a base64 String
|
/// stores the raw SHA1 bytes as a base64 String
|
||||||
|
@ -210,6 +184,10 @@ class Asset {
|
||||||
@ignore
|
@ignore
|
||||||
Duration get duration => Duration(seconds: durationInSeconds);
|
Duration get duration => Duration(seconds: durationInSeconds);
|
||||||
|
|
||||||
|
// ignore: invalid_annotation_target
|
||||||
|
@ignore
|
||||||
|
set byteHash(List<int> hash) => checksum = base64.encode(hash);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(other) {
|
bool operator ==(other) {
|
||||||
if (other is! Asset) return false;
|
if (other is! Asset) return false;
|
||||||
|
|
21
mobile/lib/interfaces/album_media.interface.dart
Normal file
21
mobile/lib/interfaces/album_media.interface.dart
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import 'package:immich_mobile/entities/album.entity.dart';
|
||||||
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
|
|
||||||
|
abstract interface class IAlbumMediaRepository {
|
||||||
|
Future<List<Album>> getAll();
|
||||||
|
|
||||||
|
Future<List<String>> getAssetIds(String albumId);
|
||||||
|
|
||||||
|
Future<int> getAssetCount(String albumId);
|
||||||
|
|
||||||
|
Future<List<Asset>> getAssets(
|
||||||
|
String albumId, {
|
||||||
|
int start = 0,
|
||||||
|
int end = 0x7fffffffffffffff,
|
||||||
|
DateTime? modifiedFrom,
|
||||||
|
DateTime? modifiedUntil,
|
||||||
|
bool orderByModificationDate = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<Album> get(String id);
|
||||||
|
}
|
7
mobile/lib/interfaces/asset_media.interface.dart
Normal file
7
mobile/lib/interfaces/asset_media.interface.dart
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
|
|
||||||
|
abstract interface class IAssetMediaRepository {
|
||||||
|
Future<List<String>> deleteAll(List<String> ids);
|
||||||
|
|
||||||
|
Future<Asset?> get(String id);
|
||||||
|
}
|
30
mobile/lib/interfaces/file_media.interface.dart
Normal file
30
mobile/lib/interfaces/file_media.interface.dart
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
|
|
||||||
|
abstract interface class IFileMediaRepository {
|
||||||
|
Future<Asset?> saveImage(
|
||||||
|
Uint8List data, {
|
||||||
|
required String title,
|
||||||
|
String? relativePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<Asset?> saveVideo(
|
||||||
|
File file, {
|
||||||
|
required String title,
|
||||||
|
String? relativePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<Asset?> saveLivePhoto({
|
||||||
|
required File image,
|
||||||
|
required File video,
|
||||||
|
required String title,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> clearFileCache();
|
||||||
|
|
||||||
|
Future<void> enableBackgroundAccess();
|
||||||
|
|
||||||
|
Future<void> requestExtendedPermissions();
|
||||||
|
}
|
|
@ -1,45 +1,47 @@
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:immich_mobile/entities/album.entity.dart';
|
||||||
|
|
||||||
class AvailableAlbum {
|
class AvailableAlbum {
|
||||||
final AssetPathEntity albumEntity;
|
final Album album;
|
||||||
|
final int assetCount;
|
||||||
final DateTime? lastBackup;
|
final DateTime? lastBackup;
|
||||||
AvailableAlbum({
|
AvailableAlbum({
|
||||||
required this.albumEntity,
|
required this.album,
|
||||||
|
required this.assetCount,
|
||||||
this.lastBackup,
|
this.lastBackup,
|
||||||
});
|
});
|
||||||
|
|
||||||
AvailableAlbum copyWith({
|
AvailableAlbum copyWith({
|
||||||
AssetPathEntity? albumEntity,
|
Album? album,
|
||||||
|
int? assetCount,
|
||||||
DateTime? lastBackup,
|
DateTime? lastBackup,
|
||||||
Uint8List? thumbnailData,
|
Uint8List? thumbnailData,
|
||||||
}) {
|
}) {
|
||||||
return AvailableAlbum(
|
return AvailableAlbum(
|
||||||
albumEntity: albumEntity ?? this.albumEntity,
|
album: album ?? this.album,
|
||||||
|
assetCount: assetCount ?? this.assetCount,
|
||||||
lastBackup: lastBackup ?? this.lastBackup,
|
lastBackup: lastBackup ?? this.lastBackup,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String get name => albumEntity.name;
|
String get name => album.name;
|
||||||
|
|
||||||
Future<int> get assetCount => albumEntity.assetCountAsync;
|
String get id => album.localId!;
|
||||||
|
|
||||||
String get id => albumEntity.id;
|
bool get isAll => album.isAll;
|
||||||
|
|
||||||
bool get isAll => albumEntity.isAll;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() =>
|
String toString() =>
|
||||||
'AvailableAlbum(albumEntity: $albumEntity, lastBackup: $lastBackup)';
|
'AvailableAlbum(albumEntity: $album, lastBackup: $lastBackup)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (identical(this, other)) return true;
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
return other is AvailableAlbum && other.albumEntity == albumEntity;
|
return other is AvailableAlbum && other.album == album;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => albumEntity.hashCode;
|
int get hashCode => album.hashCode;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
|
|
||||||
class BackupCandidate {
|
class BackupCandidate {
|
||||||
BackupCandidate({required this.asset, required this.albumNames});
|
BackupCandidate({required this.asset, required this.albumNames});
|
||||||
|
|
||||||
AssetEntity asset;
|
Asset asset;
|
||||||
List<String> albumNames;
|
List<String> albumNames;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
|
|
||||||
class ErrorUploadAsset {
|
class ErrorUploadAsset {
|
||||||
final String id;
|
final String id;
|
||||||
final DateTime fileCreatedAt;
|
final DateTime fileCreatedAt;
|
||||||
final String fileName;
|
final String fileName;
|
||||||
final String fileType;
|
final String fileType;
|
||||||
final AssetEntity asset;
|
final Asset asset;
|
||||||
final String errorMessage;
|
final String errorMessage;
|
||||||
|
|
||||||
const ErrorUploadAsset({
|
const ErrorUploadAsset({
|
||||||
|
@ -22,7 +22,7 @@ class ErrorUploadAsset {
|
||||||
DateTime? fileCreatedAt,
|
DateTime? fileCreatedAt,
|
||||||
String? fileName,
|
String? fileName,
|
||||||
String? fileType,
|
String? fileType,
|
||||||
AssetEntity? asset,
|
Asset? asset,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
}) {
|
}) {
|
||||||
return ErrorUploadAsset(
|
return ErrorUploadAsset(
|
||||||
|
|
|
@ -1,28 +1,27 @@
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
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/entities/album.entity.dart';
|
||||||
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
import 'package:immich_mobile/repositories/album_media.repository.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
class AlbumPreviewPage extends HookConsumerWidget {
|
class AlbumPreviewPage extends HookConsumerWidget {
|
||||||
final AssetPathEntity album;
|
final Album album;
|
||||||
const AlbumPreviewPage({super.key, required this.album});
|
const AlbumPreviewPage({super.key, required this.album});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final assets = useState<List<AssetEntity>>([]);
|
final assets = useState<List<Asset>>([]);
|
||||||
|
|
||||||
getAssetsInAlbum() async {
|
getAssetsInAlbum() async {
|
||||||
assets.value = await album.getAssetListRange(
|
assets.value = await ref
|
||||||
start: 0,
|
.read(albumMediaRepositoryProvider)
|
||||||
end: await album.assetCountAsync,
|
.getAssets(album.localId!);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
|
@ -68,30 +67,10 @@ class AlbumPreviewPage extends HookConsumerWidget {
|
||||||
),
|
),
|
||||||
itemCount: assets.value.length,
|
itemCount: assets.value.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
Future<Uint8List?> thumbData =
|
return ImmichThumbnail(
|
||||||
assets.value[index].thumbnailDataWithSize(
|
asset: assets.value[index],
|
||||||
const ThumbnailSize(200, 200),
|
|
||||||
quality: 50,
|
|
||||||
);
|
|
||||||
|
|
||||||
return FutureBuilder<Uint8List?>(
|
|
||||||
future: thumbData,
|
|
||||||
builder: ((context, snapshot) {
|
|
||||||
if (snapshot.hasData && snapshot.data != null) {
|
|
||||||
return Image.memory(
|
|
||||||
snapshot.data!,
|
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 100,
|
height: 100,
|
||||||
fit: BoxFit.cover,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return const SizedBox(
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
child: ImmichLoadingIndicator(),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
@ -3,9 +3,8 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
|
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
|
||||||
import 'package:photo_manager_image_provider/photo_manager_image_provider.dart';
|
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
class FailedBackupStatusPage extends HookConsumerWidget {
|
class FailedBackupStatusPage extends HookConsumerWidget {
|
||||||
|
@ -70,11 +69,10 @@ class FailedBackupStatusPage extends HookConsumerWidget {
|
||||||
clipBehavior: Clip.hardEdge,
|
clipBehavior: Clip.hardEdge,
|
||||||
child: Image(
|
child: Image(
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
image: AssetEntityImageProvider(
|
image: ImmichLocalThumbnailProvider(
|
||||||
errorAsset.asset,
|
asset: errorAsset.asset,
|
||||||
isOriginal: false,
|
height: 512,
|
||||||
thumbnailSize: const ThumbnailSize.square(512),
|
width: 512,
|
||||||
thumbnailFormat: ThumbnailFormat.jpeg,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -8,11 +8,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_image.dart';
|
import 'package:immich_mobile/widgets/common/immich_image.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
|
||||||
import 'package:immich_mobile/providers/album/album.provider.dart';
|
import 'package:immich_mobile/providers/album/album.provider.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
@ -67,7 +67,7 @@ class EditImagePage extends ConsumerWidget {
|
||||||
) async {
|
) async {
|
||||||
try {
|
try {
|
||||||
final Uint8List imageData = await _imageToUint8List(image);
|
final Uint8List imageData = await _imageToUint8List(image);
|
||||||
await PhotoManager.editor.saveImage(
|
await ref.read(fileMediaRepositoryProvider).saveImage(
|
||||||
imageData,
|
imageData,
|
||||||
title: "${p.withoutExtension(asset.fileName)}_edited.jpg",
|
title: "${p.withoutExtension(asset.fileName)}_edited.jpg",
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
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/providers/memory.provider.dart';
|
import 'package:immich_mobile/providers/memory.provider.dart';
|
||||||
|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||||
import 'package:immich_mobile/services/album.service.dart';
|
import 'package:immich_mobile/services/album.service.dart';
|
||||||
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
@ -15,7 +16,6 @@ import 'package:immich_mobile/utils/db.dart';
|
||||||
import 'package:immich_mobile/utils/renderlist_generator.dart';
|
import 'package:immich_mobile/utils/renderlist_generator.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
|
||||||
|
|
||||||
class AssetNotifier extends StateNotifier<bool> {
|
class AssetNotifier extends StateNotifier<bool> {
|
||||||
final AssetService _assetService;
|
final AssetService _assetService;
|
||||||
|
@ -257,7 +257,7 @@ class AssetNotifier extends StateNotifier<bool> {
|
||||||
// Delete asset from device
|
// Delete asset from device
|
||||||
if (local.isNotEmpty) {
|
if (local.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
return await PhotoManager.editor.deleteWithIds(local);
|
return await _ref.read(assetMediaRepositoryProvider).deleteAll(local);
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
log.severe("Failed to delete asset from device", e, stack);
|
log.severe("Failed to delete asset from device", e, stack);
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,9 @@ import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/entities/album.entity.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/album_media.interface.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/file_media.interface.dart';
|
||||||
import 'package:immich_mobile/models/backup/available_album.model.dart';
|
import 'package:immich_mobile/models/backup/available_album.model.dart';
|
||||||
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
||||||
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
|
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
|
||||||
|
@ -13,6 +16,8 @@ import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
||||||
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
|
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
|
||||||
import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
|
import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
|
||||||
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
|
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
|
||||||
|
import 'package:immich_mobile/repositories/album_media.repository.dart';
|
||||||
|
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
||||||
import 'package:immich_mobile/services/background.service.dart';
|
import 'package:immich_mobile/services/background.service.dart';
|
||||||
import 'package:immich_mobile/services/backup.service.dart';
|
import 'package:immich_mobile/services/backup.service.dart';
|
||||||
import 'package:immich_mobile/models/authentication/authentication_state.model.dart';
|
import 'package:immich_mobile/models/authentication/authentication_state.model.dart';
|
||||||
|
@ -28,7 +33,7 @@ import 'package:immich_mobile/utils/diff.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
||||||
|
|
||||||
class BackupNotifier extends StateNotifier<BackUpState> {
|
class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
BackupNotifier(
|
BackupNotifier(
|
||||||
|
@ -38,6 +43,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
this._backgroundService,
|
this._backgroundService,
|
||||||
this._galleryPermissionNotifier,
|
this._galleryPermissionNotifier,
|
||||||
this._db,
|
this._db,
|
||||||
|
this._albumMediaRepository,
|
||||||
|
this._fileMediaRepository,
|
||||||
this.ref,
|
this.ref,
|
||||||
) : super(
|
) : super(
|
||||||
BackUpState(
|
BackUpState(
|
||||||
|
@ -86,6 +93,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
final BackgroundService _backgroundService;
|
final BackgroundService _backgroundService;
|
||||||
final GalleryPermissionNotifier _galleryPermissionNotifier;
|
final GalleryPermissionNotifier _galleryPermissionNotifier;
|
||||||
final Isar _db;
|
final Isar _db;
|
||||||
|
final IAlbumMediaRepository _albumMediaRepository;
|
||||||
|
final IFileMediaRepository _fileMediaRepository;
|
||||||
final Ref ref;
|
final Ref ref;
|
||||||
|
|
||||||
///
|
///
|
||||||
|
@ -224,22 +233,24 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
Stopwatch stopwatch = Stopwatch()..start();
|
Stopwatch stopwatch = Stopwatch()..start();
|
||||||
// Get all albums on the device
|
// Get all albums on the device
|
||||||
List<AvailableAlbum> availableAlbums = [];
|
List<AvailableAlbum> availableAlbums = [];
|
||||||
List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(
|
List<Album> albums = await _albumMediaRepository.getAll();
|
||||||
hasAll: true,
|
|
||||||
type: RequestType.common,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Map of id -> album for quick album lookup later on.
|
// Map of id -> album for quick album lookup later on.
|
||||||
Map<String, AssetPathEntity> albumMap = {};
|
Map<String, Album> albumMap = {};
|
||||||
|
|
||||||
log.info('Found ${albums.length} local albums');
|
log.info('Found ${albums.length} local albums');
|
||||||
|
|
||||||
for (AssetPathEntity album in albums) {
|
for (Album album in albums) {
|
||||||
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
|
AvailableAlbum availableAlbum = AvailableAlbum(
|
||||||
|
album: album,
|
||||||
|
assetCount: await ref
|
||||||
|
.read(albumMediaRepositoryProvider)
|
||||||
|
.getAssetCount(album.localId!),
|
||||||
|
);
|
||||||
|
|
||||||
availableAlbums.add(availableAlbum);
|
availableAlbums.add(availableAlbum);
|
||||||
|
|
||||||
albumMap[album.id] = album;
|
albumMap[album.localId!] = album;
|
||||||
}
|
}
|
||||||
state = state.copyWith(availableAlbums: availableAlbums);
|
state = state.copyWith(availableAlbums: availableAlbums);
|
||||||
|
|
||||||
|
@ -248,14 +259,18 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
final List<BackupAlbum> selectedBackupAlbums =
|
final List<BackupAlbum> selectedBackupAlbums =
|
||||||
await _backupService.selectedAlbumsQuery().findAll();
|
await _backupService.selectedAlbumsQuery().findAll();
|
||||||
|
|
||||||
// Generate AssetPathEntity from id to add to local state
|
|
||||||
final Set<AvailableAlbum> selectedAlbums = {};
|
final Set<AvailableAlbum> selectedAlbums = {};
|
||||||
for (final BackupAlbum ba in selectedBackupAlbums) {
|
for (final BackupAlbum ba in selectedBackupAlbums) {
|
||||||
final albumAsset = albumMap[ba.id];
|
final albumAsset = albumMap[ba.id];
|
||||||
|
|
||||||
if (albumAsset != null) {
|
if (albumAsset != null) {
|
||||||
selectedAlbums.add(
|
selectedAlbums.add(
|
||||||
AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup),
|
AvailableAlbum(
|
||||||
|
album: albumAsset,
|
||||||
|
assetCount:
|
||||||
|
await _albumMediaRepository.getAssetCount(albumAsset.localId!),
|
||||||
|
lastBackup: ba.lastBackup,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
log.severe('Selected album not found');
|
log.severe('Selected album not found');
|
||||||
|
@ -268,7 +283,13 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
|
|
||||||
if (albumAsset != null) {
|
if (albumAsset != null) {
|
||||||
excludedAlbums.add(
|
excludedAlbums.add(
|
||||||
AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup),
|
AvailableAlbum(
|
||||||
|
album: albumAsset,
|
||||||
|
assetCount: await ref
|
||||||
|
.read(albumMediaRepositoryProvider)
|
||||||
|
.getAssetCount(albumAsset.localId!),
|
||||||
|
lastBackup: ba.lastBackup,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
log.severe('Excluded album not found');
|
log.severe('Excluded album not found');
|
||||||
|
@ -297,23 +318,24 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
final Set<BackupCandidate> assetsFromExcludedAlbums = {};
|
final Set<BackupCandidate> assetsFromExcludedAlbums = {};
|
||||||
|
|
||||||
for (final album in state.selectedBackupAlbums) {
|
for (final album in state.selectedBackupAlbums) {
|
||||||
final assetCount = await album.albumEntity.assetCountAsync;
|
final assetCount = await ref
|
||||||
|
.read(albumMediaRepositoryProvider)
|
||||||
|
.getAssetCount(album.album.localId!);
|
||||||
|
|
||||||
if (assetCount == 0) {
|
if (assetCount == 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
final assets = await album.albumEntity.getAssetListRange(
|
final assets = await ref
|
||||||
start: 0,
|
.read(albumMediaRepositoryProvider)
|
||||||
end: assetCount,
|
.getAssets(album.album.localId!);
|
||||||
);
|
|
||||||
|
|
||||||
// Add album's name to the asset info
|
// Add album's name to the asset info
|
||||||
for (final asset in assets) {
|
for (final asset in assets) {
|
||||||
List<String> albumNames = [album.name];
|
List<String> albumNames = [album.name];
|
||||||
|
|
||||||
final existingAsset = assetsFromSelectedAlbums.firstWhereOrNull(
|
final existingAsset = assetsFromSelectedAlbums.firstWhereOrNull(
|
||||||
(a) => a.asset.id == asset.id,
|
(a) => a.asset.localId == asset.localId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingAsset != null) {
|
if (existingAsset != null) {
|
||||||
|
@ -331,16 +353,17 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (final album in state.excludedBackupAlbums) {
|
for (final album in state.excludedBackupAlbums) {
|
||||||
final assetCount = await album.albumEntity.assetCountAsync;
|
final assetCount = await ref
|
||||||
|
.read(albumMediaRepositoryProvider)
|
||||||
|
.getAssetCount(album.album.localId!);
|
||||||
|
|
||||||
if (assetCount == 0) {
|
if (assetCount == 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
final assets = await album.albumEntity.getAssetListRange(
|
final assets = await ref
|
||||||
start: 0,
|
.read(albumMediaRepositoryProvider)
|
||||||
end: assetCount,
|
.getAssets(album.album.localId!);
|
||||||
);
|
|
||||||
|
|
||||||
for (final asset in assets) {
|
for (final asset in assets) {
|
||||||
assetsFromExcludedAlbums.add(
|
assetsFromExcludedAlbums.add(
|
||||||
|
@ -360,14 +383,14 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
|
|
||||||
// Find asset that were backup from selected albums
|
// Find asset that were backup from selected albums
|
||||||
final Set<String> selectedAlbumsBackupAssets =
|
final Set<String> selectedAlbumsBackupAssets =
|
||||||
Set.from(allUniqueAssets.map((e) => e.asset.id));
|
Set.from(allUniqueAssets.map((e) => e.asset.localId));
|
||||||
|
|
||||||
selectedAlbumsBackupAssets
|
selectedAlbumsBackupAssets
|
||||||
.removeWhere((assetId) => !allAssetsInDatabase.contains(assetId));
|
.removeWhere((assetId) => !allAssetsInDatabase.contains(assetId));
|
||||||
|
|
||||||
// Remove duplicated asset from all unique assets
|
// Remove duplicated asset from all unique assets
|
||||||
allUniqueAssets.removeWhere(
|
allUniqueAssets.removeWhere(
|
||||||
(candidate) => duplicatedAssetIds.contains(candidate.asset.id),
|
(candidate) => duplicatedAssetIds.contains(candidate.asset.localId),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (allUniqueAssets.isEmpty) {
|
if (allUniqueAssets.isEmpty) {
|
||||||
|
@ -454,7 +477,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
|
|
||||||
final hasPermission = _galleryPermissionNotifier.hasPermission;
|
final hasPermission = _galleryPermissionNotifier.hasPermission;
|
||||||
if (hasPermission) {
|
if (hasPermission) {
|
||||||
await PhotoManager.clearFileCache();
|
await _fileMediaRepository.clearFileCache();
|
||||||
|
|
||||||
if (state.allUniqueAssets.isEmpty) {
|
if (state.allUniqueAssets.isEmpty) {
|
||||||
log.info("No Asset On Device - Abort Backup Process");
|
log.info("No Asset On Device - Abort Backup Process");
|
||||||
|
@ -465,7 +488,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
Set<BackupCandidate> assetsWillBeBackup = Set.from(state.allUniqueAssets);
|
Set<BackupCandidate> assetsWillBeBackup = Set.from(state.allUniqueAssets);
|
||||||
// Remove item that has already been backed up
|
// Remove item that has already been backed up
|
||||||
for (final assetId in state.allAssetsInDatabase) {
|
for (final assetId in state.allAssetsInDatabase) {
|
||||||
assetsWillBeBackup.removeWhere((e) => e.asset.id == assetId);
|
assetsWillBeBackup.removeWhere((e) => e.asset.localId == assetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (assetsWillBeBackup.isEmpty) {
|
if (assetsWillBeBackup.isEmpty) {
|
||||||
|
@ -531,7 +554,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
allUniqueAssets: state.allUniqueAssets
|
allUniqueAssets: state.allUniqueAssets
|
||||||
.where(
|
.where(
|
||||||
(candidate) => candidate.asset.id != result.candidate.asset.id,
|
(candidate) =>
|
||||||
|
candidate.asset.localId != result.candidate.asset.localId,
|
||||||
)
|
)
|
||||||
.toSet(),
|
.toSet(),
|
||||||
);
|
);
|
||||||
|
@ -539,11 +563,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
selectedAlbumsBackupAssetsIds: {
|
selectedAlbumsBackupAssetsIds: {
|
||||||
...state.selectedAlbumsBackupAssetsIds,
|
...state.selectedAlbumsBackupAssetsIds,
|
||||||
result.candidate.asset.id,
|
result.candidate.asset.localId!,
|
||||||
},
|
},
|
||||||
allAssetsInDatabase: [
|
allAssetsInDatabase: [
|
||||||
...state.allAssetsInDatabase,
|
...state.allAssetsInDatabase,
|
||||||
result.candidate.asset.id,
|
result.candidate.asset.localId!,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -552,7 +576,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
state.selectedAlbumsBackupAssetsIds.length ==
|
state.selectedAlbumsBackupAssetsIds.length ==
|
||||||
0) {
|
0) {
|
||||||
final latestAssetBackup = state.allUniqueAssets
|
final latestAssetBackup = state.allUniqueAssets
|
||||||
.map((candidate) => candidate.asset.modifiedDateTime)
|
.map((candidate) => candidate.asset.fileModifiedAt)
|
||||||
.reduce(
|
.reduce(
|
||||||
(v, e) => e.isAfter(v) ? e : v,
|
(v, e) => e.isAfter(v) ? e : v,
|
||||||
);
|
);
|
||||||
|
@ -741,6 +765,8 @@ final backupProvider =
|
||||||
ref.watch(backgroundServiceProvider),
|
ref.watch(backgroundServiceProvider),
|
||||||
ref.watch(galleryPermissionNotifier.notifier),
|
ref.watch(galleryPermissionNotifier.notifier),
|
||||||
ref.watch(dbProvider),
|
ref.watch(dbProvider),
|
||||||
|
ref.watch(albumMediaRepositoryProvider),
|
||||||
|
ref.watch(fileMediaRepositoryProvider),
|
||||||
ref,
|
ref,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -8,6 +8,7 @@ import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
|
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
|
||||||
import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
|
import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
||||||
import 'package:immich_mobile/services/background.service.dart';
|
import 'package:immich_mobile/services/background.service.dart';
|
||||||
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
||||||
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
||||||
|
@ -27,7 +28,7 @@ import 'package:immich_mobile/utils/backup_progress.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
||||||
|
|
||||||
final manualUploadProvider =
|
final manualUploadProvider =
|
||||||
StateNotifierProvider<ManualUploadNotifier, ManualUploadState>((ref) {
|
StateNotifierProvider<ManualUploadNotifier, ManualUploadState>((ref) {
|
||||||
|
@ -193,17 +194,10 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
|
||||||
_backupProvider.updateBackupProgress(BackUpProgressEnum.manualInProgress);
|
_backupProvider.updateBackupProgress(BackUpProgressEnum.manualInProgress);
|
||||||
|
|
||||||
if (ref.read(galleryPermissionNotifier.notifier).hasPermission) {
|
if (ref.read(galleryPermissionNotifier.notifier).hasPermission) {
|
||||||
await PhotoManager.clearFileCache();
|
await ref.read(fileMediaRepositoryProvider).clearFileCache();
|
||||||
|
|
||||||
// We do not have 1:1 mapping of all AssetEntity fields to Asset. This results in cases
|
final allAssetsFromDevice =
|
||||||
// where platform specific fields such as `subtype` used to detect platform specific assets such as
|
allManualUploads.where((e) => e.isLocal && !e.isRemote).toList();
|
||||||
// LivePhoto in iOS is lost when we directly fetch the local asset from Asset using Asset.local
|
|
||||||
List<AssetEntity?> allAssetsFromDevice = await Future.wait(
|
|
||||||
allManualUploads
|
|
||||||
// Filter local only assets
|
|
||||||
.where((e) => e.isLocal && !e.isRemote)
|
|
||||||
.map((e) => e.local!.obtainForNewProperties()),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (allAssetsFromDevice.length != allManualUploads.length) {
|
if (allAssetsFromDevice.length != allManualUploads.length) {
|
||||||
_log.warning(
|
_log.warning(
|
||||||
|
@ -221,11 +215,17 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
|
||||||
await _backupService.buildUploadCandidates(
|
await _backupService.buildUploadCandidates(
|
||||||
selectedBackupAlbums,
|
selectedBackupAlbums,
|
||||||
excludedBackupAlbums,
|
excludedBackupAlbums,
|
||||||
|
useTimeFilter: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Extrack candidate from allAssetsFromDevice.nonNulls
|
// Extrack candidate from allAssetsFromDevice
|
||||||
final uploadAssets = candidates
|
final uploadAssets = candidates.where(
|
||||||
.where((e) => allAssetsFromDevice.nonNulls.contains(e.asset));
|
(candidate) =>
|
||||||
|
allAssetsFromDevice.firstWhereOrNull(
|
||||||
|
(asset) => asset.localId == candidate.asset.localId,
|
||||||
|
) !=
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
if (uploadAssets.isEmpty) {
|
if (uploadAssets.isEmpty) {
|
||||||
debugPrint("[_startUpload] No Assets to upload - Abort Process");
|
debugPrint("[_startUpload] No Assets to upload - Abort Process");
|
||||||
|
|
|
@ -9,7 +9,7 @@ import 'package:flutter/painting.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart' show ThumbnailSize;
|
||||||
|
|
||||||
/// The local image provider for an asset
|
/// The local image provider for an asset
|
||||||
class ImmichLocalImageProvider extends ImageProvider<ImmichLocalImageProvider> {
|
class ImmichLocalImageProvider extends ImageProvider<ImmichLocalImageProvider> {
|
||||||
|
|
|
@ -6,7 +6,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/painting.dart';
|
import 'package:flutter/painting.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart' show ThumbnailSize;
|
||||||
|
|
||||||
/// The local image provider for an asset
|
/// The local image provider for an asset
|
||||||
/// Only viable
|
/// Only viable
|
||||||
|
|
93
mobile/lib/repositories/album_media.repository.dart
Normal file
93
mobile/lib/repositories/album_media.repository.dart
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/entities/album.entity.dart';
|
||||||
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/album_media.interface.dart';
|
||||||
|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||||
|
import 'package:photo_manager/photo_manager.dart' hide AssetType;
|
||||||
|
|
||||||
|
final albumMediaRepositoryProvider = Provider((ref) => AlbumMediaRepository());
|
||||||
|
|
||||||
|
class AlbumMediaRepository implements IAlbumMediaRepository {
|
||||||
|
@override
|
||||||
|
Future<List<Album>> getAll() async {
|
||||||
|
final List<AssetPathEntity> assetPathEntities =
|
||||||
|
await PhotoManager.getAssetPathList(
|
||||||
|
hasAll: true,
|
||||||
|
filterOption: FilterOptionGroup(containsPathModified: true),
|
||||||
|
);
|
||||||
|
return assetPathEntities.map(_toAlbum).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> getAssetIds(String albumId) async {
|
||||||
|
final album = await AssetPathEntity.fromId(albumId);
|
||||||
|
final List<AssetEntity> assets =
|
||||||
|
await album.getAssetListRange(start: 0, end: 0x7fffffffffffffff);
|
||||||
|
return assets.map((e) => e.id).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> getAssetCount(String albumId) async {
|
||||||
|
final album = await AssetPathEntity.fromId(albumId);
|
||||||
|
return album.assetCountAsync;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Asset>> getAssets(
|
||||||
|
String albumId, {
|
||||||
|
int start = 0,
|
||||||
|
int end = 0x7fffffffffffffff,
|
||||||
|
DateTime? modifiedFrom,
|
||||||
|
DateTime? modifiedUntil,
|
||||||
|
bool orderByModificationDate = false,
|
||||||
|
}) async {
|
||||||
|
final onDevice = await AssetPathEntity.fromId(
|
||||||
|
albumId,
|
||||||
|
filterOption: FilterOptionGroup(
|
||||||
|
containsPathModified: true,
|
||||||
|
orders: orderByModificationDate
|
||||||
|
? [const OrderOption(type: OrderOptionType.updateDate)]
|
||||||
|
: [],
|
||||||
|
imageOption: const FilterOption(needTitle: true),
|
||||||
|
videoOption: const FilterOption(needTitle: true),
|
||||||
|
updateTimeCond: modifiedFrom == null && modifiedUntil == null
|
||||||
|
? null
|
||||||
|
: DateTimeCond(
|
||||||
|
min: modifiedFrom ?? DateTime.utc(-271820),
|
||||||
|
max: modifiedUntil ?? DateTime.utc(275760),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final List<AssetEntity> assets =
|
||||||
|
await onDevice.getAssetListRange(start: start, end: end);
|
||||||
|
return assets.map(AssetMediaRepository.toAsset).toList().cast();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Album> get(
|
||||||
|
String id, {
|
||||||
|
DateTime? modifiedFrom,
|
||||||
|
DateTime? modifiedUntil,
|
||||||
|
}) async {
|
||||||
|
final assetPathEntity = await AssetPathEntity.fromId(id);
|
||||||
|
return _toAlbum(assetPathEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Album _toAlbum(AssetPathEntity assetPathEntity) {
|
||||||
|
final Album album = Album(
|
||||||
|
name: assetPathEntity.name,
|
||||||
|
createdAt:
|
||||||
|
assetPathEntity.lastModified?.toUtc() ?? DateTime.now().toUtc(),
|
||||||
|
modifiedAt:
|
||||||
|
assetPathEntity.lastModified?.toUtc() ?? DateTime.now().toUtc(),
|
||||||
|
shared: false,
|
||||||
|
activityEnabled: false,
|
||||||
|
);
|
||||||
|
album.owner.value = Store.get(StoreKey.currentUser);
|
||||||
|
album.localId = assetPathEntity.id;
|
||||||
|
album.isAll = assetPathEntity.isAll;
|
||||||
|
return album;
|
||||||
|
}
|
||||||
|
}
|
46
mobile/lib/repositories/asset_media.repository.dart
Normal file
46
mobile/lib/repositories/asset_media.repository.dart
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
|
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
||||||
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/asset_media.interface.dart';
|
||||||
|
import 'package:photo_manager/photo_manager.dart' hide AssetType;
|
||||||
|
|
||||||
|
final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository());
|
||||||
|
|
||||||
|
class AssetMediaRepository implements IAssetMediaRepository {
|
||||||
|
@override
|
||||||
|
Future<List<String>> deleteAll(List<String> ids) =>
|
||||||
|
PhotoManager.editor.deleteWithIds(ids);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Asset?> get(String id) async {
|
||||||
|
final entity = await AssetEntity.fromId(id);
|
||||||
|
return toAsset(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Asset? toAsset(AssetEntity? local) {
|
||||||
|
if (local == null) return null;
|
||||||
|
final Asset asset = Asset(
|
||||||
|
checksum: "",
|
||||||
|
localId: local.id,
|
||||||
|
ownerId: Store.get(StoreKey.currentUser).isarId,
|
||||||
|
fileCreatedAt: local.createDateTime,
|
||||||
|
fileModifiedAt: local.modifiedDateTime,
|
||||||
|
updatedAt: local.modifiedDateTime,
|
||||||
|
durationInSeconds: local.duration,
|
||||||
|
type: AssetType.values[local.typeInt],
|
||||||
|
fileName: local.title!,
|
||||||
|
width: local.width,
|
||||||
|
height: local.height,
|
||||||
|
isFavorite: local.isFavorite,
|
||||||
|
);
|
||||||
|
if (asset.fileCreatedAt.year == 1970) {
|
||||||
|
asset.fileCreatedAt = asset.fileModifiedAt;
|
||||||
|
}
|
||||||
|
if (local.latitude != null) {
|
||||||
|
asset.exifInfo = ExifInfo(lat: local.latitude, long: local.longitude);
|
||||||
|
}
|
||||||
|
asset.local = local;
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
}
|
62
mobile/lib/repositories/file_media.repository.dart
Normal file
62
mobile/lib/repositories/file_media.repository.dart
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/file_media.interface.dart';
|
||||||
|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||||
|
import 'package:photo_manager/photo_manager.dart' hide AssetType;
|
||||||
|
|
||||||
|
final fileMediaRepositoryProvider = Provider((ref) => FileMediaRepository());
|
||||||
|
|
||||||
|
class FileMediaRepository implements IFileMediaRepository {
|
||||||
|
@override
|
||||||
|
Future<Asset?> saveImage(
|
||||||
|
Uint8List data, {
|
||||||
|
required String title,
|
||||||
|
String? relativePath,
|
||||||
|
}) async {
|
||||||
|
final entity = await PhotoManager.editor
|
||||||
|
.saveImage(data, title: title, relativePath: relativePath);
|
||||||
|
return AssetMediaRepository.toAsset(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Asset?> saveLivePhoto({
|
||||||
|
required File image,
|
||||||
|
required File video,
|
||||||
|
required String title,
|
||||||
|
}) async {
|
||||||
|
final entity = await PhotoManager.editor.darwin.saveLivePhoto(
|
||||||
|
imageFile: image,
|
||||||
|
videoFile: video,
|
||||||
|
title: title,
|
||||||
|
);
|
||||||
|
return AssetMediaRepository.toAsset(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Asset?> saveVideo(
|
||||||
|
File file, {
|
||||||
|
required String title,
|
||||||
|
String? relativePath,
|
||||||
|
}) async {
|
||||||
|
final entity = await PhotoManager.editor.saveVideo(
|
||||||
|
file,
|
||||||
|
title: title,
|
||||||
|
relativePath: relativePath,
|
||||||
|
);
|
||||||
|
return AssetMediaRepository.toAsset(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clearFileCache() => PhotoManager.clearFileCache();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> enableBackgroundAccess() =>
|
||||||
|
PhotoManager.setIgnorePermissionCheck(true);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> requestExtendedPermissions() =>
|
||||||
|
PhotoManager.requestPermissionExtend();
|
||||||
|
}
|
|
@ -63,7 +63,6 @@ import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
|
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart' hide LatLng;
|
|
||||||
|
|
||||||
part 'router.gr.dart';
|
part 'router.gr.dart';
|
||||||
|
|
||||||
|
|
|
@ -185,7 +185,7 @@ class AlbumOptionsRouteArgs {
|
||||||
class AlbumPreviewRoute extends PageRouteInfo<AlbumPreviewRouteArgs> {
|
class AlbumPreviewRoute extends PageRouteInfo<AlbumPreviewRouteArgs> {
|
||||||
AlbumPreviewRoute({
|
AlbumPreviewRoute({
|
||||||
Key? key,
|
Key? key,
|
||||||
required AssetPathEntity album,
|
required Album album,
|
||||||
List<PageRouteInfo>? children,
|
List<PageRouteInfo>? children,
|
||||||
}) : super(
|
}) : super(
|
||||||
AlbumPreviewRoute.name,
|
AlbumPreviewRoute.name,
|
||||||
|
@ -218,7 +218,7 @@ class AlbumPreviewRouteArgs {
|
||||||
|
|
||||||
final Key? key;
|
final Key? key;
|
||||||
|
|
||||||
final AssetPathEntity album;
|
final Album album;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
|
|
|
@ -6,6 +6,7 @@ import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/interfaces/album.interface.dart';
|
import 'package:immich_mobile/interfaces/album.interface.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/album_media.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/asset.interface.dart';
|
import 'package:immich_mobile/interfaces/asset.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/backup.interface.dart';
|
import 'package:immich_mobile/interfaces/backup.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/user.interface.dart';
|
import 'package:immich_mobile/interfaces/user.interface.dart';
|
||||||
|
@ -19,13 +20,13 @@ import 'package:immich_mobile/providers/api.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/album.repository.dart';
|
import 'package:immich_mobile/repositories/album.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/asset.repository.dart';
|
import 'package:immich_mobile/repositories/asset.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/backup.repository.dart';
|
import 'package:immich_mobile/repositories/backup.repository.dart';
|
||||||
|
import 'package:immich_mobile/repositories/album_media.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/user.repository.dart';
|
import 'package:immich_mobile/repositories/user.repository.dart';
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:immich_mobile/services/sync.service.dart';
|
import 'package:immich_mobile/services/sync.service.dart';
|
||||||
import 'package:immich_mobile/services/user.service.dart';
|
import 'package:immich_mobile/services/user.service.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
|
||||||
|
|
||||||
final albumServiceProvider = Provider(
|
final albumServiceProvider = Provider(
|
||||||
(ref) => AlbumService(
|
(ref) => AlbumService(
|
||||||
|
@ -36,6 +37,7 @@ final albumServiceProvider = Provider(
|
||||||
ref.watch(assetRepositoryProvider),
|
ref.watch(assetRepositoryProvider),
|
||||||
ref.watch(userRepositoryProvider),
|
ref.watch(userRepositoryProvider),
|
||||||
ref.watch(backupRepositoryProvider),
|
ref.watch(backupRepositoryProvider),
|
||||||
|
ref.watch(albumMediaRepositoryProvider),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -47,6 +49,7 @@ class AlbumService {
|
||||||
final IAssetRepository _assetRepository;
|
final IAssetRepository _assetRepository;
|
||||||
final IUserRepository _userRepository;
|
final IUserRepository _userRepository;
|
||||||
final IBackupRepository _backupAlbumRepository;
|
final IBackupRepository _backupAlbumRepository;
|
||||||
|
final IAlbumMediaRepository _albumMediaRepository;
|
||||||
final Logger _log = Logger('AlbumService');
|
final Logger _log = Logger('AlbumService');
|
||||||
Completer<bool> _localCompleter = Completer()..complete(false);
|
Completer<bool> _localCompleter = Completer()..complete(false);
|
||||||
Completer<bool> _remoteCompleter = Completer()..complete(false);
|
Completer<bool> _remoteCompleter = Completer()..complete(false);
|
||||||
|
@ -59,6 +62,7 @@ class AlbumService {
|
||||||
this._assetRepository,
|
this._assetRepository,
|
||||||
this._userRepository,
|
this._userRepository,
|
||||||
this._backupAlbumRepository,
|
this._backupAlbumRepository,
|
||||||
|
this._albumMediaRepository,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Checks all selected device albums for changes of albums and their assets
|
/// Checks all selected device albums for changes of albums and their assets
|
||||||
|
@ -84,11 +88,7 @@ class AlbumService {
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
final List<AssetPathEntity> onDevice =
|
final List<Album> onDevice = await _albumMediaRepository.getAll();
|
||||||
await PhotoManager.getAssetPathList(
|
|
||||||
hasAll: true,
|
|
||||||
filterOption: FilterOptionGroup(containsPathModified: true),
|
|
||||||
);
|
|
||||||
_log.info("Found ${onDevice.length} device albums");
|
_log.info("Found ${onDevice.length} device albums");
|
||||||
Set<String>? excludedAssets;
|
Set<String>? excludedAssets;
|
||||||
if (excludedIds.isNotEmpty) {
|
if (excludedIds.isNotEmpty) {
|
||||||
|
@ -104,13 +104,15 @@ class AlbumService {
|
||||||
_log.info("Found ${excludedAssets.length} assets to exclude");
|
_log.info("Found ${excludedAssets.length} assets to exclude");
|
||||||
}
|
}
|
||||||
// remove all excluded albums
|
// remove all excluded albums
|
||||||
onDevice.removeWhere((e) => excludedIds.contains(e.id));
|
onDevice.removeWhere((e) => excludedIds.contains(e.localId));
|
||||||
_log.info(
|
_log.info(
|
||||||
"Ignoring ${excludedIds.length} excluded albums resulting in ${onDevice.length} device albums",
|
"Ignoring ${excludedIds.length} excluded albums resulting in ${onDevice.length} device albums",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final hasAll = selectedIds
|
final hasAll = selectedIds
|
||||||
.map((id) => onDevice.firstWhereOrNull((a) => a.id == id))
|
.map(
|
||||||
|
(id) => onDevice.firstWhereOrNull((album) => album.localId == id),
|
||||||
|
)
|
||||||
.whereNotNull()
|
.whereNotNull()
|
||||||
.any((a) => a.isAll);
|
.any((a) => a.isAll);
|
||||||
if (hasAll) {
|
if (hasAll) {
|
||||||
|
@ -122,7 +124,7 @@ class AlbumService {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// keep only the explicitly selected albums
|
// keep only the explicitly selected albums
|
||||||
onDevice.removeWhere((e) => !selectedIds.contains(e.id));
|
onDevice.removeWhere((e) => !selectedIds.contains(e.localId));
|
||||||
_log.info("'Recents' is not selected, keeping only selected albums");
|
_log.info("'Recents' is not selected, keeping only selected albums");
|
||||||
}
|
}
|
||||||
changes =
|
changes =
|
||||||
|
@ -136,15 +138,15 @@ class AlbumService {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Set<String>> _loadExcludedAssetIds(
|
Future<Set<String>> _loadExcludedAssetIds(
|
||||||
List<AssetPathEntity> albums,
|
List<Album> albums,
|
||||||
List<String> excludedAlbumIds,
|
List<String> excludedAlbumIds,
|
||||||
) async {
|
) async {
|
||||||
final Set<String> result = HashSet<String>();
|
final Set<String> result = HashSet<String>();
|
||||||
for (AssetPathEntity a in albums) {
|
for (Album album in albums) {
|
||||||
if (excludedAlbumIds.contains(a.id)) {
|
if (excludedAlbumIds.contains(album.localId)) {
|
||||||
final List<AssetEntity> assets =
|
final assetIds =
|
||||||
await a.getAssetListRange(start: 0, end: 0x7fffffffffffffff);
|
await _albumMediaRepository.getAssetIds(album.localId!);
|
||||||
result.addAll(assets.map((e) => e.id));
|
result.addAll(assetIds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|
|
@ -321,7 +321,7 @@ class AssetService {
|
||||||
|
|
||||||
for (BackupCandidate candidate in candidates) {
|
for (BackupCandidate candidate in candidates) {
|
||||||
final asset = remoteAssets.firstWhereOrNull(
|
final asset = remoteAssets.firstWhereOrNull(
|
||||||
(a) => a.localId == candidate.asset.id,
|
(a) => a.localId == candidate.asset.localId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (asset != null) {
|
if (asset != null) {
|
||||||
|
|
|
@ -15,6 +15,8 @@ import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
|
||||||
import 'package:immich_mobile/repositories/album.repository.dart';
|
import 'package:immich_mobile/repositories/album.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/asset.repository.dart';
|
import 'package:immich_mobile/repositories/asset.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/backup.repository.dart';
|
import 'package:immich_mobile/repositories/backup.repository.dart';
|
||||||
|
import 'package:immich_mobile/repositories/album_media.repository.dart';
|
||||||
|
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/user.repository.dart';
|
import 'package:immich_mobile/repositories/user.repository.dart';
|
||||||
import 'package:immich_mobile/services/album.service.dart';
|
import 'package:immich_mobile/services/album.service.dart';
|
||||||
import 'package:immich_mobile/services/hash.service.dart';
|
import 'package:immich_mobile/services/hash.service.dart';
|
||||||
|
@ -34,7 +36,7 @@ import 'package:immich_mobile/utils/diff.dart';
|
||||||
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
|
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
import 'package:path_provider_ios/path_provider_ios.dart';
|
import 'package:path_provider_ios/path_provider_ios.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
||||||
|
|
||||||
final backgroundServiceProvider = Provider(
|
final backgroundServiceProvider = Provider(
|
||||||
(ref) => BackgroundService(),
|
(ref) => BackgroundService(),
|
||||||
|
@ -363,8 +365,10 @@ class BackgroundService {
|
||||||
AssetRepository assetRepository = AssetRepository(db);
|
AssetRepository assetRepository = AssetRepository(db);
|
||||||
UserRepository userRepository = UserRepository(db);
|
UserRepository userRepository = UserRepository(db);
|
||||||
BackupRepository backupAlbumRepository = BackupRepository(db);
|
BackupRepository backupAlbumRepository = BackupRepository(db);
|
||||||
HashService hashService = HashService(db, this);
|
AlbumMediaRepository albumMediaRepository = AlbumMediaRepository();
|
||||||
SyncService syncSerive = SyncService(db, hashService);
|
FileMediaRepository fileMediaRepository = FileMediaRepository();
|
||||||
|
HashService hashService = HashService(db, this, albumMediaRepository);
|
||||||
|
SyncService syncSerive = SyncService(db, hashService, albumMediaRepository);
|
||||||
UserService userService =
|
UserService userService =
|
||||||
UserService(apiService, db, syncSerive, partnerService);
|
UserService(apiService, db, syncSerive, partnerService);
|
||||||
AlbumService albumService = AlbumService(
|
AlbumService albumService = AlbumService(
|
||||||
|
@ -375,9 +379,16 @@ class BackgroundService {
|
||||||
assetRepository,
|
assetRepository,
|
||||||
userRepository,
|
userRepository,
|
||||||
backupAlbumRepository,
|
backupAlbumRepository,
|
||||||
|
albumMediaRepository,
|
||||||
|
);
|
||||||
|
BackupService backupService = BackupService(
|
||||||
|
apiService,
|
||||||
|
db,
|
||||||
|
settingService,
|
||||||
|
albumService,
|
||||||
|
albumMediaRepository,
|
||||||
|
fileMediaRepository,
|
||||||
);
|
);
|
||||||
BackupService backupService =
|
|
||||||
BackupService(apiService, db, settingService, albumService);
|
|
||||||
|
|
||||||
final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync();
|
final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync();
|
||||||
final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync();
|
final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync();
|
||||||
|
@ -385,7 +396,7 @@ class BackgroundService {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
await PhotoManager.setIgnorePermissionCheck(true);
|
await fileMediaRepository.enableBackgroundAccess();
|
||||||
|
|
||||||
do {
|
do {
|
||||||
final bool backupOk = await _runBackup(
|
final bool backupOk = await _runBackup(
|
||||||
|
|
|
@ -6,9 +6,13 @@ import 'package:cancellation_token_http/http.dart' as http;
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.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/entities/album.entity.dart';
|
||||||
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
||||||
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
|
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/album_media.interface.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/file_media.interface.dart';
|
||||||
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
|
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
|
||||||
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
||||||
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
|
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
|
||||||
|
@ -16,6 +20,8 @@ import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
|
||||||
import 'package:immich_mobile/providers/api.provider.dart';
|
import 'package:immich_mobile/providers/api.provider.dart';
|
||||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/db.provider.dart';
|
import 'package:immich_mobile/providers/db.provider.dart';
|
||||||
|
import 'package:immich_mobile/repositories/album_media.repository.dart';
|
||||||
|
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
||||||
import 'package:immich_mobile/services/album.service.dart';
|
import 'package:immich_mobile/services/album.service.dart';
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
|
@ -24,7 +30,7 @@ import 'package:logging/logging.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:permission_handler/permission_handler.dart' as pm;
|
import 'package:permission_handler/permission_handler.dart' as pm;
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
||||||
|
|
||||||
final backupServiceProvider = Provider(
|
final backupServiceProvider = Provider(
|
||||||
(ref) => BackupService(
|
(ref) => BackupService(
|
||||||
|
@ -32,6 +38,8 @@ final backupServiceProvider = Provider(
|
||||||
ref.watch(dbProvider),
|
ref.watch(dbProvider),
|
||||||
ref.watch(appSettingsServiceProvider),
|
ref.watch(appSettingsServiceProvider),
|
||||||
ref.watch(albumServiceProvider),
|
ref.watch(albumServiceProvider),
|
||||||
|
ref.watch(albumMediaRepositoryProvider),
|
||||||
|
ref.watch(fileMediaRepositoryProvider),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -42,12 +50,16 @@ class BackupService {
|
||||||
final Logger _log = Logger("BackupService");
|
final Logger _log = Logger("BackupService");
|
||||||
final AppSettingsService _appSetting;
|
final AppSettingsService _appSetting;
|
||||||
final AlbumService _albumService;
|
final AlbumService _albumService;
|
||||||
|
final IAlbumMediaRepository _albumMediaRepository;
|
||||||
|
final IFileMediaRepository _fileMediaRepository;
|
||||||
|
|
||||||
BackupService(
|
BackupService(
|
||||||
this._apiService,
|
this._apiService,
|
||||||
this._db,
|
this._db,
|
||||||
this._appSetting,
|
this._appSetting,
|
||||||
this._albumService,
|
this._albumService,
|
||||||
|
this._albumMediaRepository,
|
||||||
|
this._fileMediaRepository,
|
||||||
);
|
);
|
||||||
|
|
||||||
Future<List<String>?> getDeviceBackupAsset() async {
|
Future<List<String>?> getDeviceBackupAsset() async {
|
||||||
|
@ -86,44 +98,17 @@ class BackupService {
|
||||||
List<BackupAlbum> excludedBackupAlbums, {
|
List<BackupAlbum> excludedBackupAlbums, {
|
||||||
bool useTimeFilter = true,
|
bool useTimeFilter = true,
|
||||||
}) async {
|
}) async {
|
||||||
final filter = FilterOptionGroup(
|
|
||||||
containsPathModified: true,
|
|
||||||
orders: [const OrderOption(type: OrderOptionType.updateDate)],
|
|
||||||
// title is needed to create Assets
|
|
||||||
imageOption: const FilterOption(needTitle: true),
|
|
||||||
videoOption: const FilterOption(needTitle: true),
|
|
||||||
);
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
|
|
||||||
final List<AssetPathEntity?> selectedAlbums =
|
|
||||||
await _loadAlbumsWithTimeFilter(
|
|
||||||
selectedBackupAlbums,
|
|
||||||
filter,
|
|
||||||
now,
|
|
||||||
useTimeFilter: useTimeFilter,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (selectedAlbums.every((e) => e == null)) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
final List<AssetPathEntity?> excludedAlbums =
|
|
||||||
await _loadAlbumsWithTimeFilter(
|
|
||||||
excludedBackupAlbums,
|
|
||||||
filter,
|
|
||||||
now,
|
|
||||||
useTimeFilter: useTimeFilter,
|
|
||||||
);
|
|
||||||
|
|
||||||
final Set<BackupCandidate> toAdd = await _fetchAssetsAndUpdateLastBackup(
|
final Set<BackupCandidate> toAdd = await _fetchAssetsAndUpdateLastBackup(
|
||||||
selectedAlbums,
|
|
||||||
selectedBackupAlbums,
|
selectedBackupAlbums,
|
||||||
now,
|
now,
|
||||||
useTimeFilter: useTimeFilter,
|
useTimeFilter: useTimeFilter,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (toAdd.isEmpty) return {};
|
||||||
|
|
||||||
final Set<BackupCandidate> toRemove = await _fetchAssetsAndUpdateLastBackup(
|
final Set<BackupCandidate> toRemove = await _fetchAssetsAndUpdateLastBackup(
|
||||||
excludedAlbums,
|
|
||||||
excludedBackupAlbums,
|
excludedBackupAlbums,
|
||||||
now,
|
now,
|
||||||
useTimeFilter: useTimeFilter,
|
useTimeFilter: useTimeFilter,
|
||||||
|
@ -132,92 +117,62 @@ class BackupService {
|
||||||
return toAdd.difference(toRemove);
|
return toAdd.difference(toRemove);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<AssetPathEntity?>> _loadAlbumsWithTimeFilter(
|
|
||||||
List<BackupAlbum> albums,
|
|
||||||
FilterOptionGroup filter,
|
|
||||||
DateTime now, {
|
|
||||||
bool useTimeFilter = true,
|
|
||||||
}) async {
|
|
||||||
List<AssetPathEntity?> result = [];
|
|
||||||
for (BackupAlbum backupAlbum in albums) {
|
|
||||||
try {
|
|
||||||
final optionGroup = useTimeFilter
|
|
||||||
? filter.copyWith(
|
|
||||||
updateTimeCond: DateTimeCond(
|
|
||||||
// subtract 2 seconds to prevent missing assets due to rounding issues
|
|
||||||
min: backupAlbum.lastBackup
|
|
||||||
.subtract(const Duration(seconds: 2)),
|
|
||||||
max: now,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: filter;
|
|
||||||
|
|
||||||
final AssetPathEntity album =
|
|
||||||
await AssetPathEntity.obtainPathFromProperties(
|
|
||||||
id: backupAlbum.id,
|
|
||||||
optionGroup: optionGroup,
|
|
||||||
maxDateTimeToNow: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
result.add(album);
|
|
||||||
} on StateError {
|
|
||||||
// either there are no assets matching the filter criteria OR the album no longer exists
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Set<BackupCandidate>> _fetchAssetsAndUpdateLastBackup(
|
Future<Set<BackupCandidate>> _fetchAssetsAndUpdateLastBackup(
|
||||||
List<AssetPathEntity?> localAlbums,
|
|
||||||
List<BackupAlbum> backupAlbums,
|
List<BackupAlbum> backupAlbums,
|
||||||
DateTime now, {
|
DateTime now, {
|
||||||
bool useTimeFilter = true,
|
bool useTimeFilter = true,
|
||||||
}) async {
|
}) async {
|
||||||
Set<BackupCandidate> candidate = {};
|
Set<BackupCandidate> candidates = {};
|
||||||
|
|
||||||
for (int i = 0; i < localAlbums.length; i++) {
|
for (final BackupAlbum backupAlbum in backupAlbums) {
|
||||||
final localAlbum = localAlbums[i];
|
final Album localAlbum;
|
||||||
if (localAlbum == null) {
|
try {
|
||||||
|
localAlbum = await _albumMediaRepository.get(backupAlbum.id);
|
||||||
|
} on StateError {
|
||||||
|
// the album no longer exists
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (useTimeFilter &&
|
if (useTimeFilter &&
|
||||||
localAlbum.lastModified?.isBefore(backupAlbums[i].lastBackup) ==
|
localAlbum.modifiedAt.isBefore(backupAlbum.lastBackup)) {
|
||||||
true) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
final List<Asset> assets;
|
||||||
final assets = await localAlbum.getAssetListRange(
|
try {
|
||||||
start: 0,
|
assets = await _albumMediaRepository.getAssets(
|
||||||
end: await localAlbum.assetCountAsync,
|
backupAlbum.id,
|
||||||
|
modifiedFrom: useTimeFilter
|
||||||
|
?
|
||||||
|
// subtract 2 seconds to prevent missing assets due to rounding issues
|
||||||
|
backupAlbum.lastBackup.subtract(const Duration(seconds: 2))
|
||||||
|
: null,
|
||||||
|
modifiedUntil: useTimeFilter ? now : null,
|
||||||
);
|
);
|
||||||
|
} on StateError {
|
||||||
|
// either there are no assets matching the filter criteria OR the album no longer exists
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Add album's name to the asset info
|
// Add album's name to the asset info
|
||||||
for (final asset in assets) {
|
for (final asset in assets) {
|
||||||
List<String> albumNames = [localAlbum.name];
|
List<String> albumNames = [localAlbum.name];
|
||||||
|
|
||||||
final existingAsset = candidate.firstWhereOrNull(
|
final existingAsset = candidates.firstWhereOrNull(
|
||||||
(a) => a.asset.id == asset.id,
|
(candidate) => candidate.asset.localId == asset.localId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingAsset != null) {
|
if (existingAsset != null) {
|
||||||
albumNames.addAll(existingAsset.albumNames);
|
albumNames.addAll(existingAsset.albumNames);
|
||||||
candidate.remove(existingAsset);
|
candidates.remove(existingAsset);
|
||||||
}
|
}
|
||||||
|
|
||||||
candidate.add(
|
candidates.add(BackupCandidate(asset: asset, albumNames: albumNames));
|
||||||
BackupCandidate(
|
|
||||||
asset: asset,
|
|
||||||
albumNames: albumNames,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
backupAlbums[i].lastBackup = now;
|
backupAlbum.lastBackup = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
return candidate;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a new list of assets not yet uploaded
|
/// Returns a new list of assets not yet uploaded
|
||||||
|
@ -230,7 +185,7 @@ class BackupService {
|
||||||
|
|
||||||
final Set<String> duplicatedAssetIds = await getDuplicatedAssetIds();
|
final Set<String> duplicatedAssetIds = await getDuplicatedAssetIds();
|
||||||
candidates.removeWhere(
|
candidates.removeWhere(
|
||||||
(candidate) => duplicatedAssetIds.contains(candidate.asset.id),
|
(candidate) => duplicatedAssetIds.contains(candidate.asset.localId),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (candidates.isEmpty) {
|
if (candidates.isEmpty) {
|
||||||
|
@ -243,7 +198,7 @@ class BackupService {
|
||||||
final CheckExistingAssetsResponseDto? duplicates =
|
final CheckExistingAssetsResponseDto? duplicates =
|
||||||
await _apiService.assetsApi.checkExistingAssets(
|
await _apiService.assetsApi.checkExistingAssets(
|
||||||
CheckExistingAssetsDto(
|
CheckExistingAssetsDto(
|
||||||
deviceAssetIds: candidates.map((c) => c.asset.id).toList(),
|
deviceAssetIds: candidates.map((c) => c.asset.localId!).toList(),
|
||||||
deviceId: deviceId,
|
deviceId: deviceId,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -259,7 +214,7 @@ class BackupService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existing.isNotEmpty) {
|
if (existing.isNotEmpty) {
|
||||||
candidates.removeWhere((c) => existing.contains(c.asset.id));
|
candidates.removeWhere((c) => existing.contains(c.asset.localId));
|
||||||
}
|
}
|
||||||
|
|
||||||
return candidates;
|
return candidates;
|
||||||
|
@ -278,7 +233,7 @@ class BackupService {
|
||||||
|
|
||||||
// DON'T KNOW WHY BUT THIS HELPS BACKGROUND BACKUP TO WORK ON IOS
|
// DON'T KNOW WHY BUT THIS HELPS BACKGROUND BACKUP TO WORK ON IOS
|
||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
await PhotoManager.requestPermissionExtend();
|
await _fileMediaRepository.requestExtendedPermissions();
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
@ -289,9 +244,9 @@ class BackupService {
|
||||||
List<BackupCandidate> _sortPhotosFirst(List<BackupCandidate> candidates) {
|
List<BackupCandidate> _sortPhotosFirst(List<BackupCandidate> candidates) {
|
||||||
return candidates.sorted(
|
return candidates.sorted(
|
||||||
(a, b) {
|
(a, b) {
|
||||||
final cmp = a.asset.typeInt - b.asset.typeInt;
|
final cmp = a.asset.type.index - b.asset.type.index;
|
||||||
if (cmp != 0) return cmp;
|
if (cmp != 0) return cmp;
|
||||||
return a.asset.createDateTime.compareTo(b.asset.createDateTime);
|
return a.asset.fileCreatedAt.compareTo(b.asset.fileCreatedAt);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -325,13 +280,13 @@ class BackupService {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (final candidate in candidates) {
|
for (final candidate in candidates) {
|
||||||
final AssetEntity entity = candidate.asset;
|
final Asset asset = candidate.asset;
|
||||||
File? file;
|
File? file;
|
||||||
File? livePhotoFile;
|
File? livePhotoFile;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final isAvailableLocally =
|
final isAvailableLocally =
|
||||||
await entity.isLocallyAvailable(isOrigin: true);
|
await asset.local!.isLocallyAvailable(isOrigin: true);
|
||||||
|
|
||||||
// Handle getting files from iCloud
|
// Handle getting files from iCloud
|
||||||
if (!isAvailableLocally && Platform.isIOS) {
|
if (!isAvailableLocally && Platform.isIOS) {
|
||||||
|
@ -342,39 +297,41 @@ class BackupService {
|
||||||
|
|
||||||
onCurrentAsset(
|
onCurrentAsset(
|
||||||
CurrentUploadAsset(
|
CurrentUploadAsset(
|
||||||
id: entity.id,
|
id: asset.localId!,
|
||||||
fileCreatedAt: entity.createDateTime.year == 1970
|
fileCreatedAt: asset.fileCreatedAt.year == 1970
|
||||||
? entity.modifiedDateTime
|
? asset.fileModifiedAt
|
||||||
: entity.createDateTime,
|
: asset.fileCreatedAt,
|
||||||
fileName: await entity.titleAsync,
|
fileName: asset.fileName,
|
||||||
fileType: _getAssetType(entity.type),
|
fileType: _getAssetType(asset.type),
|
||||||
iCloudAsset: true,
|
iCloudAsset: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
file = await entity.loadFile(progressHandler: pmProgressHandler);
|
file =
|
||||||
if (entity.isLivePhoto) {
|
await asset.local!.loadFile(progressHandler: pmProgressHandler);
|
||||||
livePhotoFile = await entity.loadFile(
|
if (asset.local!.isLivePhoto) {
|
||||||
|
livePhotoFile = await asset.local!.loadFile(
|
||||||
withSubtype: true,
|
withSubtype: true,
|
||||||
progressHandler: pmProgressHandler,
|
progressHandler: pmProgressHandler,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (entity.type == AssetType.video) {
|
if (asset.type == AssetType.video) {
|
||||||
file = await entity.originFile;
|
file = await asset.local!.originFile;
|
||||||
} else {
|
} else {
|
||||||
file = await entity.originFile.timeout(const Duration(seconds: 5));
|
file = await asset.local!.originFile
|
||||||
if (entity.isLivePhoto) {
|
.timeout(const Duration(seconds: 5));
|
||||||
livePhotoFile = await entity.originFileWithSubtype
|
if (asset.local!.isLivePhoto) {
|
||||||
|
livePhotoFile = await asset.local!.originFileWithSubtype
|
||||||
.timeout(const Duration(seconds: 5));
|
.timeout(const Duration(seconds: 5));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
String originalFileName = await entity.titleAsync;
|
String originalFileName = asset.fileName;
|
||||||
|
|
||||||
if (entity.isLivePhoto) {
|
if (asset.local!.isLivePhoto) {
|
||||||
if (livePhotoFile == null) {
|
if (livePhotoFile == null) {
|
||||||
_log.warning(
|
_log.warning(
|
||||||
"Failed to obtain motion part of the livePhoto - $originalFileName",
|
"Failed to obtain motion part of the livePhoto - $originalFileName",
|
||||||
|
@ -398,31 +355,31 @@ class BackupService {
|
||||||
|
|
||||||
baseRequest.headers.addAll(ApiService.getRequestHeaders());
|
baseRequest.headers.addAll(ApiService.getRequestHeaders());
|
||||||
baseRequest.headers["Transfer-Encoding"] = "chunked";
|
baseRequest.headers["Transfer-Encoding"] = "chunked";
|
||||||
baseRequest.fields['deviceAssetId'] = entity.id;
|
baseRequest.fields['deviceAssetId'] = asset.localId!;
|
||||||
baseRequest.fields['deviceId'] = deviceId;
|
baseRequest.fields['deviceId'] = deviceId;
|
||||||
baseRequest.fields['fileCreatedAt'] =
|
baseRequest.fields['fileCreatedAt'] =
|
||||||
entity.createDateTime.toUtc().toIso8601String();
|
asset.fileCreatedAt.toUtc().toIso8601String();
|
||||||
baseRequest.fields['fileModifiedAt'] =
|
baseRequest.fields['fileModifiedAt'] =
|
||||||
entity.modifiedDateTime.toUtc().toIso8601String();
|
asset.fileModifiedAt.toUtc().toIso8601String();
|
||||||
baseRequest.fields['isFavorite'] = entity.isFavorite.toString();
|
baseRequest.fields['isFavorite'] = asset.isFavorite.toString();
|
||||||
baseRequest.fields['duration'] = entity.videoDuration.toString();
|
baseRequest.fields['duration'] = asset.duration.toString();
|
||||||
baseRequest.files.add(assetRawUploadData);
|
baseRequest.files.add(assetRawUploadData);
|
||||||
|
|
||||||
onCurrentAsset(
|
onCurrentAsset(
|
||||||
CurrentUploadAsset(
|
CurrentUploadAsset(
|
||||||
id: entity.id,
|
id: asset.localId!,
|
||||||
fileCreatedAt: entity.createDateTime.year == 1970
|
fileCreatedAt: asset.fileCreatedAt.year == 1970
|
||||||
? entity.modifiedDateTime
|
? asset.fileModifiedAt
|
||||||
: entity.createDateTime,
|
: asset.fileCreatedAt,
|
||||||
fileName: originalFileName,
|
fileName: originalFileName,
|
||||||
fileType: _getAssetType(entity.type),
|
fileType: _getAssetType(asset.type),
|
||||||
fileSize: file.lengthSync(),
|
fileSize: file.lengthSync(),
|
||||||
iCloudAsset: false,
|
iCloudAsset: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
String? livePhotoVideoId;
|
String? livePhotoVideoId;
|
||||||
if (entity.isLivePhoto && livePhotoFile != null) {
|
if (asset.local!.isLivePhoto && livePhotoFile != null) {
|
||||||
livePhotoVideoId = await uploadLivePhotoVideo(
|
livePhotoVideoId = await uploadLivePhotoVideo(
|
||||||
originalFileName,
|
originalFileName,
|
||||||
livePhotoFile,
|
livePhotoFile,
|
||||||
|
@ -448,16 +405,16 @@ class BackupService {
|
||||||
final errorMessage = error['message'] ?? error['error'];
|
final errorMessage = error['message'] ?? error['error'];
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
"Error(${error['statusCode']}) uploading ${entity.id} | $originalFileName | Created on ${entity.createDateTime} | ${error['error']}",
|
"Error(${error['statusCode']}) uploading ${asset.localId} | $originalFileName | Created on ${asset.fileCreatedAt} | ${error['error']}",
|
||||||
);
|
);
|
||||||
|
|
||||||
onError(
|
onError(
|
||||||
ErrorUploadAsset(
|
ErrorUploadAsset(
|
||||||
asset: entity,
|
asset: asset,
|
||||||
id: entity.id,
|
id: asset.localId!,
|
||||||
fileCreatedAt: entity.createDateTime,
|
fileCreatedAt: asset.fileCreatedAt,
|
||||||
fileName: originalFileName,
|
fileName: originalFileName,
|
||||||
fileType: _getAssetType(entity.type),
|
fileType: _getAssetType(candidate.asset.type),
|
||||||
errorMessage: errorMessage,
|
errorMessage: errorMessage,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -473,7 +430,7 @@ class BackupService {
|
||||||
bool isDuplicate = false;
|
bool isDuplicate = false;
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
isDuplicate = true;
|
isDuplicate = true;
|
||||||
duplicatedAssetIds.add(entity.id);
|
duplicatedAssetIds.add(asset.localId!);
|
||||||
}
|
}
|
||||||
|
|
||||||
onSuccess(
|
onSuccess(
|
||||||
|
|
|
@ -8,17 +8,19 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/file_media.interface.dart';
|
||||||
import 'package:immich_mobile/providers/db.provider.dart';
|
import 'package:immich_mobile/providers/db.provider.dart';
|
||||||
|
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:immich_mobile/utils/diff.dart';
|
import 'package:immich_mobile/utils/diff.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart' show PhotoManager;
|
|
||||||
|
|
||||||
/// Finds duplicates originating from missing EXIF information
|
/// Finds duplicates originating from missing EXIF information
|
||||||
class BackupVerificationService {
|
class BackupVerificationService {
|
||||||
final Isar _db;
|
final Isar _db;
|
||||||
|
final IFileMediaRepository _fileMediaRepository;
|
||||||
|
|
||||||
BackupVerificationService(this._db);
|
BackupVerificationService(this._db, this._fileMediaRepository);
|
||||||
|
|
||||||
/// Returns at most [limit] assets that were backed up without exif
|
/// Returns at most [limit] assets that were backed up without exif
|
||||||
Future<List<Asset>> findWronglyBackedUpAssets({int limit = 100}) async {
|
Future<List<Asset>> findWronglyBackedUpAssets({int limit = 100}) async {
|
||||||
|
@ -71,6 +73,7 @@ class BackupVerificationService {
|
||||||
auth: Store.get(StoreKey.accessToken),
|
auth: Store.get(StoreKey.accessToken),
|
||||||
endpoint: Store.get(StoreKey.serverEndpoint),
|
endpoint: Store.get(StoreKey.serverEndpoint),
|
||||||
rootIsolateToken: isolateToken,
|
rootIsolateToken: isolateToken,
|
||||||
|
fileMediaRepository: _fileMediaRepository,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final upper = compute(
|
final upper = compute(
|
||||||
|
@ -81,6 +84,7 @@ class BackupVerificationService {
|
||||||
auth: Store.get(StoreKey.accessToken),
|
auth: Store.get(StoreKey.accessToken),
|
||||||
endpoint: Store.get(StoreKey.serverEndpoint),
|
endpoint: Store.get(StoreKey.serverEndpoint),
|
||||||
rootIsolateToken: isolateToken,
|
rootIsolateToken: isolateToken,
|
||||||
|
fileMediaRepository: _fileMediaRepository,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
toDelete = await lower + await upper;
|
toDelete = await lower + await upper;
|
||||||
|
@ -93,6 +97,7 @@ class BackupVerificationService {
|
||||||
auth: Store.get(StoreKey.accessToken),
|
auth: Store.get(StoreKey.accessToken),
|
||||||
endpoint: Store.get(StoreKey.serverEndpoint),
|
endpoint: Store.get(StoreKey.serverEndpoint),
|
||||||
rootIsolateToken: isolateToken,
|
rootIsolateToken: isolateToken,
|
||||||
|
fileMediaRepository: _fileMediaRepository,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -106,12 +111,13 @@ class BackupVerificationService {
|
||||||
String auth,
|
String auth,
|
||||||
String endpoint,
|
String endpoint,
|
||||||
RootIsolateToken rootIsolateToken,
|
RootIsolateToken rootIsolateToken,
|
||||||
|
IFileMediaRepository fileMediaRepository,
|
||||||
}) tuple,
|
}) tuple,
|
||||||
) async {
|
) async {
|
||||||
assert(tuple.deleteCandidates.length == tuple.originals.length);
|
assert(tuple.deleteCandidates.length == tuple.originals.length);
|
||||||
final List<Asset> result = [];
|
final List<Asset> result = [];
|
||||||
BackgroundIsolateBinaryMessenger.ensureInitialized(tuple.rootIsolateToken);
|
BackgroundIsolateBinaryMessenger.ensureInitialized(tuple.rootIsolateToken);
|
||||||
await PhotoManager.setIgnorePermissionCheck(true);
|
await tuple.fileMediaRepository.enableBackgroundAccess();
|
||||||
final ApiService apiService = ApiService();
|
final ApiService apiService = ApiService();
|
||||||
apiService.setEndpoint(tuple.endpoint);
|
apiService.setEndpoint(tuple.endpoint);
|
||||||
apiService.setAccessToken(tuple.auth);
|
apiService.setAccessToken(tuple.auth);
|
||||||
|
@ -228,5 +234,6 @@ class BackupVerificationService {
|
||||||
final backupVerificationServiceProvider = Provider(
|
final backupVerificationServiceProvider = Provider(
|
||||||
(ref) => BackupVerificationService(
|
(ref) => BackupVerificationService(
|
||||||
ref.watch(dbProvider),
|
ref.watch(dbProvider),
|
||||||
|
ref.watch(fileMediaRepositoryProvider),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
@ -2,6 +2,9 @@ import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/entities/album.entity.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/album_media.interface.dart';
|
||||||
|
import 'package:immich_mobile/repositories/album_media.repository.dart';
|
||||||
import 'package:immich_mobile/services/background.service.dart';
|
import 'package:immich_mobile/services/background.service.dart';
|
||||||
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
|
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
|
@ -11,38 +14,46 @@ import 'package:immich_mobile/providers/db.provider.dart';
|
||||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
|
||||||
|
|
||||||
class HashService {
|
class HashService {
|
||||||
HashService(this._db, this._backgroundService);
|
HashService(this._db, this._backgroundService, this._albumMediaRepository);
|
||||||
final Isar _db;
|
final Isar _db;
|
||||||
final BackgroundService _backgroundService;
|
final BackgroundService _backgroundService;
|
||||||
|
final IAlbumMediaRepository _albumMediaRepository;
|
||||||
final _log = Logger('HashService');
|
final _log = Logger('HashService');
|
||||||
|
|
||||||
/// Returns all assets that were successfully hashed
|
/// Returns all assets that were successfully hashed
|
||||||
Future<List<Asset>> getHashedAssets(
|
Future<List<Asset>> getHashedAssets(
|
||||||
AssetPathEntity album, {
|
Album album, {
|
||||||
int start = 0,
|
int start = 0,
|
||||||
int end = 0x7fffffffffffffff,
|
int end = 0x7fffffffffffffff,
|
||||||
|
DateTime? modifiedFrom,
|
||||||
|
DateTime? modifiedUntil,
|
||||||
Set<String>? excludedAssets,
|
Set<String>? excludedAssets,
|
||||||
}) async {
|
}) async {
|
||||||
final entities = await album.getAssetListRange(start: start, end: end);
|
final entities = await _albumMediaRepository.getAssets(
|
||||||
|
album.localId!,
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
modifiedFrom: modifiedFrom,
|
||||||
|
modifiedUntil: modifiedUntil,
|
||||||
|
);
|
||||||
final filtered = excludedAssets == null
|
final filtered = excludedAssets == null
|
||||||
? entities
|
? entities
|
||||||
: entities.where((e) => !excludedAssets.contains(e.id)).toList();
|
: entities.where((e) => !excludedAssets.contains(e.localId!)).toList();
|
||||||
return _hashAssets(filtered);
|
return _hashAssets(filtered);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Converts a list of [AssetEntity]s to [Asset]s including only those
|
/// Processes a list of local [Asset]s, storing their hash and returning only those
|
||||||
/// that were successfully hashed. Hashes are looked up in a DB table
|
/// that were successfully hashed. Hashes are looked up in a DB table
|
||||||
/// [AndroidDeviceAsset] / [IOSDeviceAsset] by local id. Only missing
|
/// [AndroidDeviceAsset] / [IOSDeviceAsset] by local id. Only missing
|
||||||
/// entries are newly hashed and added to the DB table.
|
/// entries are newly hashed and added to the DB table.
|
||||||
Future<List<Asset>> _hashAssets(List<AssetEntity> assetEntities) async {
|
Future<List<Asset>> _hashAssets(List<Asset> assets) async {
|
||||||
const int batchFileCount = 128;
|
const int batchFileCount = 128;
|
||||||
const int batchDataSize = 1024 * 1024 * 1024; // 1GB
|
const int batchDataSize = 1024 * 1024 * 1024; // 1GB
|
||||||
|
|
||||||
final ids = assetEntities
|
final ids = assets
|
||||||
.map(Platform.isAndroid ? (a) => a.id.toInt() : (a) => a.id)
|
.map(Platform.isAndroid ? (a) => a.localId!.toInt() : (a) => a.localId!)
|
||||||
.toList();
|
.toList();
|
||||||
final List<DeviceAsset?> hashes = await _lookupHashes(ids);
|
final List<DeviceAsset?> hashes = await _lookupHashes(ids);
|
||||||
final List<DeviceAsset> toAdd = [];
|
final List<DeviceAsset> toAdd = [];
|
||||||
|
@ -50,22 +61,16 @@ class HashService {
|
||||||
|
|
||||||
int bytes = 0;
|
int bytes = 0;
|
||||||
|
|
||||||
for (int i = 0; i < assetEntities.length; i++) {
|
for (int i = 0; i < assets.length; i++) {
|
||||||
if (hashes[i] != null) {
|
if (hashes[i] != null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
final file = await assetEntities[i].originFile;
|
final file = await assets[i].local!.originFile;
|
||||||
if (file == null) {
|
if (file == null) {
|
||||||
final fileName = await assetEntities[i].titleAsync.catchError((error) {
|
final fileName = assets[i].fileName;
|
||||||
_log.warning(
|
|
||||||
"Failed to get title for asset ${assetEntities[i].id}",
|
|
||||||
);
|
|
||||||
|
|
||||||
return "";
|
|
||||||
});
|
|
||||||
|
|
||||||
_log.warning(
|
_log.warning(
|
||||||
"Failed to get file for asset ${assetEntities[i].id}, name: $fileName, created on: ${assetEntities[i].createDateTime}, skipping",
|
"Failed to get file for asset ${assets[i].localId}, name: $fileName, created on: ${assets[i].fileCreatedAt}, skipping",
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
@ -86,7 +91,7 @@ class HashService {
|
||||||
if (toHash.isNotEmpty) {
|
if (toHash.isNotEmpty) {
|
||||||
await _processBatch(toHash, toAdd);
|
await _processBatch(toHash, toAdd);
|
||||||
}
|
}
|
||||||
return _mapAllHashedAssets(assetEntities, hashes);
|
return _getHashedAssets(assets, hashes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lookup hashes of assets by their local ID
|
/// Lookup hashes of assets by their local ID
|
||||||
|
@ -133,15 +138,16 @@ class HashService {
|
||||||
return hashes;
|
return hashes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Converts [AssetEntity]s that were successfully hashed to [Asset]s
|
/// Returns all successfully hashed [Asset]s with their hash value set
|
||||||
List<Asset> _mapAllHashedAssets(
|
List<Asset> _getHashedAssets(
|
||||||
List<AssetEntity> assets,
|
List<Asset> assets,
|
||||||
List<DeviceAsset?> hashes,
|
List<DeviceAsset?> hashes,
|
||||||
) {
|
) {
|
||||||
final List<Asset> result = [];
|
final List<Asset> result = [];
|
||||||
for (int i = 0; i < assets.length; i++) {
|
for (int i = 0; i < assets.length; i++) {
|
||||||
if (hashes[i] != null && hashes[i]!.hash.isNotEmpty) {
|
if (hashes[i] != null && hashes[i]!.hash.isNotEmpty) {
|
||||||
result.add(Asset.local(assets[i], hashes[i]!.hash));
|
assets[i].byteHash = hashes[i]!.hash;
|
||||||
|
result.add(assets[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
@ -152,5 +158,6 @@ final hashServiceProvider = Provider(
|
||||||
(ref) => HashService(
|
(ref) => HashService(
|
||||||
ref.watch(dbProvider),
|
ref.watch(dbProvider),
|
||||||
ref.watch(backgroundServiceProvider),
|
ref.watch(backgroundServiceProvider),
|
||||||
|
ref.watch(albumMediaRepositoryProvider),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
@ -3,21 +3,27 @@ import 'dart:io';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/extensions/response_extensions.dart';
|
import 'package:immich_mobile/extensions/response_extensions.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/file_media.interface.dart';
|
||||||
import 'package:immich_mobile/providers/api.provider.dart';
|
import 'package:immich_mobile/providers/api.provider.dart';
|
||||||
|
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
final imageViewerServiceProvider =
|
final imageViewerServiceProvider = Provider(
|
||||||
Provider((ref) => ImageViewerService(ref.watch(apiServiceProvider)));
|
(ref) => ImageViewerService(
|
||||||
|
ref.watch(apiServiceProvider),
|
||||||
|
ref.watch(fileMediaRepositoryProvider),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
class ImageViewerService {
|
class ImageViewerService {
|
||||||
final ApiService _apiService;
|
final ApiService _apiService;
|
||||||
|
final IFileMediaRepository _fileMediaRepository;
|
||||||
final Logger _log = Logger("ImageViewerService");
|
final Logger _log = Logger("ImageViewerService");
|
||||||
|
|
||||||
ImageViewerService(this._apiService);
|
ImageViewerService(this._apiService, this._fileMediaRepository);
|
||||||
|
|
||||||
Future<bool> downloadAsset(Asset asset) async {
|
Future<bool> downloadAsset(Asset asset) async {
|
||||||
File? imageFile;
|
File? imageFile;
|
||||||
|
@ -46,7 +52,7 @@ class ImageViewerService {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
AssetEntity? entity;
|
Asset? resultAsset;
|
||||||
|
|
||||||
final tempDir = await getTemporaryDirectory();
|
final tempDir = await getTemporaryDirectory();
|
||||||
videoFile = await File('${tempDir.path}/livephoto.mov').create();
|
videoFile = await File('${tempDir.path}/livephoto.mov').create();
|
||||||
|
@ -54,24 +60,21 @@ class ImageViewerService {
|
||||||
videoFile.writeAsBytesSync(motionResponse.bodyBytes);
|
videoFile.writeAsBytesSync(motionResponse.bodyBytes);
|
||||||
imageFile.writeAsBytesSync(imageResponse.bodyBytes);
|
imageFile.writeAsBytesSync(imageResponse.bodyBytes);
|
||||||
|
|
||||||
entity = await PhotoManager.editor.darwin.saveLivePhoto(
|
resultAsset = await _fileMediaRepository.saveLivePhoto(
|
||||||
imageFile: imageFile,
|
image: imageFile,
|
||||||
videoFile: videoFile,
|
video: videoFile,
|
||||||
title: asset.fileName,
|
title: asset.fileName,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (entity == null) {
|
if (resultAsset == null) {
|
||||||
_log.warning(
|
_log.warning(
|
||||||
"Asset cannot be saved as a live photo. This is most likely a motion photo. Saving only the image file",
|
"Asset cannot be saved as a live photo. This is most likely a motion photo. Saving only the image file",
|
||||||
);
|
);
|
||||||
|
resultAsset = await _fileMediaRepository
|
||||||
entity = await PhotoManager.editor.saveImage(
|
.saveImage(imageResponse.bodyBytes, title: asset.fileName);
|
||||||
imageResponse.bodyBytes,
|
|
||||||
title: asset.fileName,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return entity != null;
|
return resultAsset != null;
|
||||||
} else {
|
} else {
|
||||||
var res = await _apiService.assetsApi
|
var res = await _apiService.assetsApi
|
||||||
.downloadAssetWithHttpInfo(asset.remoteId!);
|
.downloadAssetWithHttpInfo(asset.remoteId!);
|
||||||
|
@ -81,11 +84,11 @@ class ImageViewerService {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final AssetEntity? entity;
|
final Asset? resultAsset;
|
||||||
final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null;
|
final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null;
|
||||||
|
|
||||||
if (asset.isImage) {
|
if (asset.isImage) {
|
||||||
entity = await PhotoManager.editor.saveImage(
|
resultAsset = await _fileMediaRepository.saveImage(
|
||||||
res.bodyBytes,
|
res.bodyBytes,
|
||||||
title: asset.fileName,
|
title: asset.fileName,
|
||||||
relativePath: relativePath,
|
relativePath: relativePath,
|
||||||
|
@ -94,13 +97,13 @@ class ImageViewerService {
|
||||||
final tempDir = await getTemporaryDirectory();
|
final tempDir = await getTemporaryDirectory();
|
||||||
videoFile = await File('${tempDir.path}/${asset.fileName}').create();
|
videoFile = await File('${tempDir.path}/${asset.fileName}').create();
|
||||||
videoFile.writeAsBytesSync(res.bodyBytes);
|
videoFile.writeAsBytesSync(res.bodyBytes);
|
||||||
entity = await PhotoManager.editor.saveVideo(
|
resultAsset = await _fileMediaRepository.saveVideo(
|
||||||
videoFile,
|
videoFile,
|
||||||
title: asset.fileName,
|
title: asset.fileName,
|
||||||
relativePath: relativePath,
|
relativePath: relativePath,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return entity != null;
|
return resultAsset != null;
|
||||||
}
|
}
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
_log.severe("Error saving downloaded asset", error, stack);
|
_log.severe("Error saving downloaded asset", error, stack);
|
||||||
|
|
|
@ -8,7 +8,9 @@ import 'package:immich_mobile/entities/etag.entity.dart';
|
||||||
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/entities/user.entity.dart';
|
import 'package:immich_mobile/entities/user.entity.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/album_media.interface.dart';
|
||||||
import 'package:immich_mobile/providers/db.provider.dart';
|
import 'package:immich_mobile/providers/db.provider.dart';
|
||||||
|
import 'package:immich_mobile/repositories/album_media.repository.dart';
|
||||||
import 'package:immich_mobile/services/hash.service.dart';
|
import 'package:immich_mobile/services/hash.service.dart';
|
||||||
import 'package:immich_mobile/utils/async_mutex.dart';
|
import 'package:immich_mobile/utils/async_mutex.dart';
|
||||||
import 'package:immich_mobile/extensions/collection_extensions.dart';
|
import 'package:immich_mobile/extensions/collection_extensions.dart';
|
||||||
|
@ -17,19 +19,23 @@ import 'package:immich_mobile/utils/diff.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
|
||||||
|
|
||||||
final syncServiceProvider = Provider(
|
final syncServiceProvider = Provider(
|
||||||
(ref) => SyncService(ref.watch(dbProvider), ref.watch(hashServiceProvider)),
|
(ref) => SyncService(
|
||||||
|
ref.watch(dbProvider),
|
||||||
|
ref.watch(hashServiceProvider),
|
||||||
|
ref.watch(albumMediaRepositoryProvider),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
class SyncService {
|
class SyncService {
|
||||||
final Isar _db;
|
final Isar _db;
|
||||||
final HashService _hashService;
|
final HashService _hashService;
|
||||||
|
final IAlbumMediaRepository _albumMediaRepository;
|
||||||
final AsyncMutex _lock = AsyncMutex();
|
final AsyncMutex _lock = AsyncMutex();
|
||||||
final Logger _log = Logger('SyncService');
|
final Logger _log = Logger('SyncService');
|
||||||
|
|
||||||
SyncService(this._db, this._hashService);
|
SyncService(this._db, this._hashService, this._albumMediaRepository);
|
||||||
|
|
||||||
// public methods:
|
// public methods:
|
||||||
|
|
||||||
|
@ -68,7 +74,7 @@ class SyncService {
|
||||||
/// Syncs all device albums and their assets to the database
|
/// Syncs all device albums and their assets to the database
|
||||||
/// Returns `true` if there were any changes
|
/// Returns `true` if there were any changes
|
||||||
Future<bool> syncLocalAlbumAssetsToDb(
|
Future<bool> syncLocalAlbumAssetsToDb(
|
||||||
List<AssetPathEntity> onDevice, [
|
List<Album> onDevice, [
|
||||||
Set<String>? excludedAssets,
|
Set<String>? excludedAssets,
|
||||||
]) =>
|
]) =>
|
||||||
_lock.run(() => _syncLocalAlbumAssetsToDb(onDevice, excludedAssets));
|
_lock.run(() => _syncLocalAlbumAssetsToDb(onDevice, excludedAssets));
|
||||||
|
@ -492,7 +498,7 @@ class SyncService {
|
||||||
/// Syncs all device albums and their assets to the database
|
/// Syncs all device albums and their assets to the database
|
||||||
/// Returns `true` if there were any changes
|
/// Returns `true` if there were any changes
|
||||||
Future<bool> _syncLocalAlbumAssetsToDb(
|
Future<bool> _syncLocalAlbumAssetsToDb(
|
||||||
List<AssetPathEntity> onDevice, [
|
List<Album> onDevice, [
|
||||||
Set<String>? excludedAssets,
|
Set<String>? excludedAssets,
|
||||||
]) async {
|
]) async {
|
||||||
onDevice.sort((a, b) => a.id.compareTo(b.id));
|
onDevice.sort((a, b) => a.id.compareTo(b.id));
|
||||||
|
@ -504,16 +510,15 @@ class SyncService {
|
||||||
final bool anyChanges = await diffSortedLists(
|
final bool anyChanges = await diffSortedLists(
|
||||||
onDevice,
|
onDevice,
|
||||||
inDb,
|
inDb,
|
||||||
compare: (AssetPathEntity a, Album b) => a.id.compareTo(b.localId!),
|
compare: (Album a, Album b) => a.localId!.compareTo(b.localId!),
|
||||||
both: (AssetPathEntity ape, Album album) => _syncAlbumInDbAndOnDevice(
|
both: (Album a, Album b) => _syncAlbumInDbAndOnDevice(
|
||||||
ape,
|
a,
|
||||||
album,
|
b,
|
||||||
deleteCandidates,
|
deleteCandidates,
|
||||||
existing,
|
existing,
|
||||||
excludedAssets,
|
excludedAssets,
|
||||||
),
|
),
|
||||||
onlyFirst: (AssetPathEntity ape) =>
|
onlyFirst: (Album a) => _addAlbumFromDevice(a, existing, excludedAssets),
|
||||||
_addAlbumFromDevice(ape, existing, excludedAssets),
|
|
||||||
onlySecond: (Album a) => _removeAlbumFromDb(a, deleteCandidates),
|
onlySecond: (Album a) => _removeAlbumFromDb(a, deleteCandidates),
|
||||||
);
|
);
|
||||||
_log.fine(
|
_log.fine(
|
||||||
|
@ -541,58 +546,65 @@ class SyncService {
|
||||||
/// returns `true` if there were any changes
|
/// returns `true` if there were any changes
|
||||||
/// Accumulates asset candidates to delete and those already existing in DB
|
/// Accumulates asset candidates to delete and those already existing in DB
|
||||||
Future<bool> _syncAlbumInDbAndOnDevice(
|
Future<bool> _syncAlbumInDbAndOnDevice(
|
||||||
AssetPathEntity ape,
|
Album deviceAlbum,
|
||||||
Album album,
|
Album dbAlbum,
|
||||||
List<Asset> deleteCandidates,
|
List<Asset> deleteCandidates,
|
||||||
List<Asset> existing, [
|
List<Asset> existing, [
|
||||||
Set<String>? excludedAssets,
|
Set<String>? excludedAssets,
|
||||||
bool forceRefresh = false,
|
bool forceRefresh = false,
|
||||||
]) async {
|
]) async {
|
||||||
if (!forceRefresh && !await _hasAssetPathEntityChanged(ape, album)) {
|
if (!forceRefresh && !await _hasAlbumChangeOnDevice(deviceAlbum, dbAlbum)) {
|
||||||
_log.fine("Local album ${ape.name} has not changed. Skipping sync.");
|
_log.fine(
|
||||||
|
"Local album ${deviceAlbum.name} has not changed. Skipping sync.",
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!forceRefresh &&
|
if (!forceRefresh &&
|
||||||
excludedAssets == null &&
|
excludedAssets == null &&
|
||||||
await _syncDeviceAlbumFast(ape, album)) {
|
await _syncDeviceAlbumFast(deviceAlbum, dbAlbum)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// general case, e.g. some assets have been deleted or there are excluded albums on iOS
|
// general case, e.g. some assets have been deleted or there are excluded albums on iOS
|
||||||
final inDb = await album.assets
|
final inDb = await dbAlbum.assets
|
||||||
.filter()
|
.filter()
|
||||||
.ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
|
.ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
|
||||||
.sortByChecksum()
|
.sortByChecksum()
|
||||||
.findAll();
|
.findAll();
|
||||||
assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!");
|
assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!");
|
||||||
final int assetCountOnDevice = await ape.assetCountAsync;
|
final int assetCountOnDevice =
|
||||||
final List<Asset> onDevice =
|
await _albumMediaRepository.getAssetCount(deviceAlbum.localId!);
|
||||||
await _hashService.getHashedAssets(ape, excludedAssets: excludedAssets);
|
final List<Asset> onDevice = await _hashService.getHashedAssets(
|
||||||
|
deviceAlbum,
|
||||||
|
excludedAssets: excludedAssets,
|
||||||
|
);
|
||||||
_removeDuplicates(onDevice);
|
_removeDuplicates(onDevice);
|
||||||
// _removeDuplicates sorts `onDevice` by checksum
|
// _removeDuplicates sorts `onDevice` by checksum
|
||||||
final (toAdd, toUpdate, toDelete) = _diffAssets(onDevice, inDb);
|
final (toAdd, toUpdate, toDelete) = _diffAssets(onDevice, inDb);
|
||||||
if (toAdd.isEmpty &&
|
if (toAdd.isEmpty &&
|
||||||
toUpdate.isEmpty &&
|
toUpdate.isEmpty &&
|
||||||
toDelete.isEmpty &&
|
toDelete.isEmpty &&
|
||||||
album.name == ape.name &&
|
dbAlbum.name == deviceAlbum.name &&
|
||||||
ape.lastModified != null &&
|
dbAlbum.modifiedAt.isAtSameMomentAs(deviceAlbum.modifiedAt)) {
|
||||||
album.modifiedAt.isAtSameMomentAs(ape.lastModified!)) {
|
|
||||||
// changes only affeted excluded albums
|
// changes only affeted excluded albums
|
||||||
_log.fine(
|
_log.fine(
|
||||||
"Only excluded assets in local album ${ape.name} changed. Stopping sync.",
|
"Only excluded assets in local album ${deviceAlbum.name} changed. Stopping sync.",
|
||||||
);
|
);
|
||||||
if (assetCountOnDevice !=
|
if (assetCountOnDevice !=
|
||||||
_db.eTags.getByIdSync(ape.eTagKeyAssetCount)?.assetCount) {
|
_db.eTags.getByIdSync(deviceAlbum.eTagKeyAssetCount)?.assetCount) {
|
||||||
await _db.writeTxn(
|
await _db.writeTxn(
|
||||||
() => _db.eTags.put(
|
() => _db.eTags.put(
|
||||||
ETag(id: ape.eTagKeyAssetCount, assetCount: assetCountOnDevice),
|
ETag(
|
||||||
|
id: deviceAlbum.eTagKeyAssetCount,
|
||||||
|
assetCount: assetCountOnDevice,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
_log.fine(
|
_log.fine(
|
||||||
"Syncing local album ${ape.name}. ${toAdd.length} assets to add, ${toUpdate.length} to update, ${toDelete.length} to delete",
|
"Syncing local album ${deviceAlbum.name}. ${toAdd.length} assets to add, ${toUpdate.length} to update, ${toDelete.length} to delete",
|
||||||
);
|
);
|
||||||
final (existingInDb, updated) = await _linkWithExistingFromDb(toAdd);
|
final (existingInDb, updated) = await _linkWithExistingFromDb(toAdd);
|
||||||
_log.fine(
|
_log.fine(
|
||||||
|
@ -600,28 +612,31 @@ class SyncService {
|
||||||
);
|
);
|
||||||
deleteCandidates.addAll(toDelete);
|
deleteCandidates.addAll(toDelete);
|
||||||
existing.addAll(existingInDb);
|
existing.addAll(existingInDb);
|
||||||
album.name = ape.name;
|
dbAlbum.name = deviceAlbum.name;
|
||||||
album.modifiedAt = ape.lastModified ?? DateTime.now();
|
dbAlbum.modifiedAt = deviceAlbum.modifiedAt;
|
||||||
if (album.thumbnail.value != null &&
|
if (dbAlbum.thumbnail.value != null &&
|
||||||
toDelete.contains(album.thumbnail.value)) {
|
toDelete.contains(dbAlbum.thumbnail.value)) {
|
||||||
album.thumbnail.value = null;
|
dbAlbum.thumbnail.value = null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await _db.writeTxn(() async {
|
await _db.writeTxn(() async {
|
||||||
await _db.assets.putAll(updated);
|
await _db.assets.putAll(updated);
|
||||||
await _db.assets.putAll(toUpdate);
|
await _db.assets.putAll(toUpdate);
|
||||||
await album.assets
|
await dbAlbum.assets
|
||||||
.update(link: existingInDb + updated, unlink: toDelete);
|
.update(link: existingInDb + updated, unlink: toDelete);
|
||||||
await _db.albums.put(album);
|
await _db.albums.put(dbAlbum);
|
||||||
album.thumbnail.value ??= await album.assets.filter().findFirst();
|
dbAlbum.thumbnail.value ??= await dbAlbum.assets.filter().findFirst();
|
||||||
await album.thumbnail.save();
|
await dbAlbum.thumbnail.save();
|
||||||
await _db.eTags.put(
|
await _db.eTags.put(
|
||||||
ETag(id: ape.eTagKeyAssetCount, assetCount: assetCountOnDevice),
|
ETag(
|
||||||
|
id: deviceAlbum.eTagKeyAssetCount,
|
||||||
|
assetCount: assetCountOnDevice,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
_log.info("Synced changes of local album ${ape.name} to DB");
|
_log.info("Synced changes of local album ${deviceAlbum.name} to DB");
|
||||||
} on IsarError catch (e) {
|
} on IsarError catch (e) {
|
||||||
_log.severe("Failed to update synced album ${ape.name} in DB", e);
|
_log.severe("Failed to update synced album ${deviceAlbum.name} in DB", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
@ -629,45 +644,45 @@ class SyncService {
|
||||||
|
|
||||||
/// fast path for common case: only new assets were added to device album
|
/// fast path for common case: only new assets were added to device album
|
||||||
/// returns `true` if successfull, else `false`
|
/// returns `true` if successfull, else `false`
|
||||||
Future<bool> _syncDeviceAlbumFast(AssetPathEntity ape, Album album) async {
|
Future<bool> _syncDeviceAlbumFast(Album deviceAlbum, Album dbAlbum) async {
|
||||||
if (!(ape.lastModified ?? DateTime.now()).isAfter(album.modifiedAt)) {
|
if (!deviceAlbum.modifiedAt.isAfter(dbAlbum.modifiedAt)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
final int totalOnDevice = await ape.assetCountAsync;
|
final int totalOnDevice =
|
||||||
|
await _albumMediaRepository.getAssetCount(deviceAlbum.localId!);
|
||||||
final int lastKnownTotal =
|
final int lastKnownTotal =
|
||||||
(await _db.eTags.getById(ape.eTagKeyAssetCount))?.assetCount ?? 0;
|
(await _db.eTags.getById(deviceAlbum.eTagKeyAssetCount))?.assetCount ??
|
||||||
final AssetPathEntity? modified = totalOnDevice > lastKnownTotal
|
0;
|
||||||
? await ape.fetchPathProperties(
|
if (totalOnDevice <= lastKnownTotal) {
|
||||||
filterOptionGroup: FilterOptionGroup(
|
|
||||||
updateTimeCond: DateTimeCond(
|
|
||||||
min: album.modifiedAt.add(const Duration(seconds: 1)),
|
|
||||||
max: ape.lastModified ?? DateTime.now(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
if (modified == null) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
final List<Asset> newAssets = await _hashService.getHashedAssets(modified);
|
final List<Asset> newAssets = await _hashService.getHashedAssets(
|
||||||
|
deviceAlbum,
|
||||||
|
modifiedFrom: dbAlbum.modifiedAt.add(const Duration(seconds: 1)),
|
||||||
|
modifiedUntil: deviceAlbum.modifiedAt,
|
||||||
|
);
|
||||||
|
|
||||||
if (totalOnDevice != lastKnownTotal + newAssets.length) {
|
if (totalOnDevice != lastKnownTotal + newAssets.length) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
album.modifiedAt = ape.lastModified ?? DateTime.now();
|
dbAlbum.modifiedAt = deviceAlbum.modifiedAt;
|
||||||
_removeDuplicates(newAssets);
|
_removeDuplicates(newAssets);
|
||||||
final (existingInDb, updated) = await _linkWithExistingFromDb(newAssets);
|
final (existingInDb, updated) = await _linkWithExistingFromDb(newAssets);
|
||||||
try {
|
try {
|
||||||
await _db.writeTxn(() async {
|
await _db.writeTxn(() async {
|
||||||
await _db.assets.putAll(updated);
|
await _db.assets.putAll(updated);
|
||||||
await album.assets.update(link: existingInDb + updated);
|
await dbAlbum.assets.update(link: existingInDb + updated);
|
||||||
await _db.albums.put(album);
|
await _db.albums.put(dbAlbum);
|
||||||
await _db.eTags
|
await _db.eTags.put(
|
||||||
.put(ETag(id: ape.eTagKeyAssetCount, assetCount: totalOnDevice));
|
ETag(id: deviceAlbum.eTagKeyAssetCount, assetCount: totalOnDevice),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
_log.info("Fast synced local album ${ape.name} to DB");
|
_log.info("Fast synced local album ${deviceAlbum.name} to DB");
|
||||||
} on IsarError catch (e) {
|
} on IsarError catch (e) {
|
||||||
_log.severe("Failed to fast sync local album ${ape.name} to DB", e);
|
_log.severe(
|
||||||
|
"Failed to fast sync local album ${deviceAlbum.name} to DB",
|
||||||
|
e,
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -677,14 +692,15 @@ class SyncService {
|
||||||
/// Adds a new album from the device to the database and Accumulates all
|
/// Adds a new album from the device to the database and Accumulates all
|
||||||
/// assets already existing in the database to the list of `existing` assets
|
/// assets already existing in the database to the list of `existing` assets
|
||||||
Future<void> _addAlbumFromDevice(
|
Future<void> _addAlbumFromDevice(
|
||||||
AssetPathEntity ape,
|
Album album,
|
||||||
List<Asset> existing, [
|
List<Asset> existing, [
|
||||||
Set<String>? excludedAssets,
|
Set<String>? excludedAssets,
|
||||||
]) async {
|
]) async {
|
||||||
_log.info("Syncing a new local album to DB: ${ape.name}");
|
_log.info("Syncing a new local album to DB: ${album.name}");
|
||||||
final Album a = Album.local(ape);
|
final assets = await _hashService.getHashedAssets(
|
||||||
final assets =
|
album,
|
||||||
await _hashService.getHashedAssets(ape, excludedAssets: excludedAssets);
|
excludedAssets: excludedAssets,
|
||||||
|
);
|
||||||
_removeDuplicates(assets);
|
_removeDuplicates(assets);
|
||||||
final (existingInDb, updated) = await _linkWithExistingFromDb(assets);
|
final (existingInDb, updated) = await _linkWithExistingFromDb(assets);
|
||||||
_log.info(
|
_log.info(
|
||||||
|
@ -692,15 +708,15 @@ class SyncService {
|
||||||
);
|
);
|
||||||
await upsertAssetsWithExif(updated);
|
await upsertAssetsWithExif(updated);
|
||||||
existing.addAll(existingInDb);
|
existing.addAll(existingInDb);
|
||||||
a.assets.addAll(existingInDb);
|
album.assets.addAll(existingInDb);
|
||||||
a.assets.addAll(updated);
|
album.assets.addAll(updated);
|
||||||
final thumb = existingInDb.firstOrNull ?? updated.firstOrNull;
|
final thumb = existingInDb.firstOrNull ?? updated.firstOrNull;
|
||||||
a.thumbnail.value = thumb;
|
album.thumbnail.value = thumb;
|
||||||
try {
|
try {
|
||||||
await _db.writeTxn(() => _db.albums.store(a));
|
await _db.writeTxn(() => _db.albums.store(album));
|
||||||
_log.info("Added a new local album to DB: ${ape.name}");
|
_log.info("Added a new local album to DB: ${album.name}");
|
||||||
} on IsarError catch (e) {
|
} on IsarError catch (e) {
|
||||||
_log.severe("Failed to add new local album ${ape.name} to DB", e);
|
_log.severe("Failed to add new local album ${album.name} to DB", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -798,12 +814,15 @@ class SyncService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// returns `true` if the albums differ on the surface
|
/// returns `true` if the albums differ on the surface
|
||||||
Future<bool> _hasAssetPathEntityChanged(AssetPathEntity a, Album b) async {
|
Future<bool> _hasAlbumChangeOnDevice(
|
||||||
return a.name != b.name ||
|
Album deviceAlbum,
|
||||||
a.lastModified == null ||
|
Album dbAlbum,
|
||||||
!a.lastModified!.isAtSameMomentAs(b.modifiedAt) ||
|
) async {
|
||||||
await a.assetCountAsync !=
|
return deviceAlbum.name != dbAlbum.name ||
|
||||||
(await _db.eTags.getById(a.eTagKeyAssetCount))?.assetCount;
|
!deviceAlbum.modifiedAt.isAtSameMomentAs(dbAlbum.modifiedAt) ||
|
||||||
|
await _albumMediaRepository.getAssetCount(deviceAlbum.localId!) !=
|
||||||
|
(await _db.eTags.getById(deviceAlbum.eTagKeyAssetCount))
|
||||||
|
?.assetCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _removeAllLocalAlbumsAndAssets() async {
|
Future<bool> _removeAllLocalAlbumsAndAssets() async {
|
||||||
|
|
|
@ -183,23 +183,13 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 2.0),
|
padding: const EdgeInsets.only(top: 2.0),
|
||||||
child: FutureBuilder(
|
child: Text(
|
||||||
builder: ((context, snapshot) {
|
album.assetCount.toString() +
|
||||||
if (snapshot.hasData) {
|
(album.isAll ? " (${'backup_all'.tr()})" : ""),
|
||||||
return Text(
|
|
||||||
snapshot.data.toString() +
|
|
||||||
(album.isAll
|
|
||||||
? " (${'backup_all'.tr()})"
|
|
||||||
: ""),
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: Colors.grey[600],
|
color: Colors.grey[600],
|
||||||
),
|
),
|
||||||
);
|
|
||||||
}
|
|
||||||
return const Text("0");
|
|
||||||
}),
|
|
||||||
future: album.assetCount,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -208,7 +198,7 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.pushRoute(
|
context.pushRoute(
|
||||||
AlbumPreviewRoute(album: album.albumEntity),
|
AlbumPreviewRoute(album: album.album),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
@ -24,19 +23,10 @@ class AlbumInfoListTile extends HookConsumerWidget {
|
||||||
ref.watch(backupProvider).selectedBackupAlbums.contains(album);
|
ref.watch(backupProvider).selectedBackupAlbums.contains(album);
|
||||||
final bool isExcluded =
|
final bool isExcluded =
|
||||||
ref.watch(backupProvider).excludedBackupAlbums.contains(album);
|
ref.watch(backupProvider).excludedBackupAlbums.contains(album);
|
||||||
final assetCount = useState(0);
|
|
||||||
final syncAlbum = ref
|
final syncAlbum = ref
|
||||||
.watch(appSettingsServiceProvider)
|
.watch(appSettingsServiceProvider)
|
||||||
.getSetting(AppSettingsEnum.syncAlbums);
|
.getSetting(AppSettingsEnum.syncAlbums);
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() {
|
|
||||||
album.assetCount.then((value) => assetCount.value = value);
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
[album],
|
|
||||||
);
|
|
||||||
|
|
||||||
buildTileColor() {
|
buildTileColor() {
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
return context.isDarkTheme
|
return context.isDarkTheme
|
||||||
|
@ -117,11 +107,11 @@ class AlbumInfoListTile extends HookConsumerWidget {
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
subtitle: Text(assetCount.value.toString()),
|
subtitle: Text(album.assetCount.toString()),
|
||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.pushRoute(
|
context.pushRoute(
|
||||||
AlbumPreviewRoute(album: album.albumEntity),
|
AlbumPreviewRoute(album: album.album),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
|
|
|
@ -2,18 +2,19 @@ import 'dart:io';
|
||||||
|
|
||||||
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/foundation.dart';
|
|
||||||
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/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
||||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
|
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
||||||
|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
|
||||||
|
|
||||||
class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
|
class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
|
||||||
const CurrentUploadingAssetInfoBox({super.key});
|
const CurrentUploadingAssetInfoBox({super.key});
|
||||||
|
@ -148,17 +149,6 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
buildAssetThumbnail() async {
|
|
||||||
var assetEntity = await AssetEntity.fromId(asset.id);
|
|
||||||
|
|
||||||
if (assetEntity != null) {
|
|
||||||
return assetEntity.thumbnailDataWithSize(
|
|
||||||
const ThumbnailSize(500, 500),
|
|
||||||
quality: 100,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildiCloudDownloadProgerssBar() {
|
buildiCloudDownloadProgerssBar() {
|
||||||
if (asset.iCloudAsset != null && asset.iCloudAsset!) {
|
if (asset.iCloudAsset != null && asset.iCloudAsset!) {
|
||||||
return Padding(
|
return Padding(
|
||||||
|
@ -239,8 +229,8 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return FutureBuilder<Uint8List?>(
|
return FutureBuilder<Asset?>(
|
||||||
future: buildAssetThumbnail(),
|
future: ref.read(assetMediaRepositoryProvider).get(asset.id),
|
||||||
builder: (context, thumbnail) => ListTile(
|
builder: (context, thumbnail) => ListTile(
|
||||||
isThreeLine: true,
|
isThreeLine: true,
|
||||||
leading: AnimatedCrossFade(
|
leading: AnimatedCrossFade(
|
||||||
|
@ -250,9 +240,8 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
|
||||||
child: thumbnail.hasData
|
child: thumbnail.hasData
|
||||||
? ClipRRect(
|
? ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(5),
|
borderRadius: BorderRadius.circular(5),
|
||||||
child: Image.memory(
|
child: ImmichThumbnail(
|
||||||
thumbnail.data!,
|
asset: thumbnail.data,
|
||||||
fit: BoxFit.cover,
|
|
||||||
width: 50,
|
width: 50,
|
||||||
height: 50,
|
height: 50,
|
||||||
),
|
),
|
||||||
|
|
|
@ -836,6 +836,13 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.1+1"
|
version: "0.2.1+1"
|
||||||
|
immich_mobile_immich_lint:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
path: immich_lint
|
||||||
|
relative: true
|
||||||
|
source: path
|
||||||
|
version: "0.0.0"
|
||||||
integration_test:
|
integration_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
|
@ -94,10 +94,12 @@ dev_dependencies:
|
||||||
isar_generator: ^3.1.0+1
|
isar_generator: ^3.1.0+1
|
||||||
integration_test:
|
integration_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
custom_lint: ^0.6.0
|
custom_lint: ^0.6.4
|
||||||
riverpod_lint: ^2.3.7
|
riverpod_lint: ^2.3.7
|
||||||
riverpod_generator: ^2.3.9
|
riverpod_generator: ^2.3.9
|
||||||
mocktail: ^1.0.3
|
mocktail: ^1.0.3
|
||||||
|
immich_mobile_immich_lint:
|
||||||
|
path: './immich_lint'
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
|
@ -1,11 +1,8 @@
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/entities/user.entity.dart';
|
import 'package:immich_mobile/entities/user.entity.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/services/hash.service.dart';
|
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
class MockHashService extends Mock implements HashService {}
|
|
||||||
|
|
||||||
class MockCurrentUserProvider extends StateNotifier<User?>
|
class MockCurrentUserProvider extends StateNotifier<User?>
|
||||||
with Mock
|
with Mock
|
||||||
implements CurrentUserProvider {
|
implements CurrentUserProvider {
|
||||||
|
|
|
@ -7,8 +7,9 @@ import 'package:immich_mobile/services/immich_logger.service.dart';
|
||||||
import 'package:immich_mobile/services/sync.service.dart';
|
import 'package:immich_mobile/services/sync.service.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
|
import '../../repository.mocks.dart';
|
||||||
|
import '../../service.mocks.dart';
|
||||||
import '../../test_utils.dart';
|
import '../../test_utils.dart';
|
||||||
import 'shared_mocks.dart';
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
Asset makeAsset({
|
Asset makeAsset({
|
||||||
|
@ -38,6 +39,8 @@ void main() {
|
||||||
group('Test SyncService grouped', () {
|
group('Test SyncService grouped', () {
|
||||||
late final Isar db;
|
late final Isar db;
|
||||||
final MockHashService hs = MockHashService();
|
final MockHashService hs = MockHashService();
|
||||||
|
final MockAlbumMediaRepository albumMediaRepository =
|
||||||
|
MockAlbumMediaRepository();
|
||||||
final owner = User(
|
final owner = User(
|
||||||
id: "1",
|
id: "1",
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
|
@ -67,7 +70,7 @@ void main() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
test('test inserting existing assets', () async {
|
test('test inserting existing assets', () async {
|
||||||
SyncService s = SyncService(db, hs);
|
SyncService s = SyncService(db, hs, albumMediaRepository);
|
||||||
final List<Asset> remoteAssets = [
|
final List<Asset> remoteAssets = [
|
||||||
makeAsset(checksum: "a", remoteId: "0-1"),
|
makeAsset(checksum: "a", remoteId: "0-1"),
|
||||||
makeAsset(checksum: "b", remoteId: "2-1"),
|
makeAsset(checksum: "b", remoteId: "2-1"),
|
||||||
|
@ -85,7 +88,7 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test inserting new assets', () async {
|
test('test inserting new assets', () async {
|
||||||
SyncService s = SyncService(db, hs);
|
SyncService s = SyncService(db, hs, albumMediaRepository);
|
||||||
final List<Asset> remoteAssets = [
|
final List<Asset> remoteAssets = [
|
||||||
makeAsset(checksum: "a", remoteId: "0-1"),
|
makeAsset(checksum: "a", remoteId: "0-1"),
|
||||||
makeAsset(checksum: "b", remoteId: "2-1"),
|
makeAsset(checksum: "b", remoteId: "2-1"),
|
||||||
|
@ -106,7 +109,7 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test syncing duplicate assets', () async {
|
test('test syncing duplicate assets', () async {
|
||||||
SyncService s = SyncService(db, hs);
|
SyncService s = SyncService(db, hs, albumMediaRepository);
|
||||||
final List<Asset> remoteAssets = [
|
final List<Asset> remoteAssets = [
|
||||||
makeAsset(checksum: "a", remoteId: "0-1"),
|
makeAsset(checksum: "a", remoteId: "0-1"),
|
||||||
makeAsset(checksum: "b", remoteId: "1-1"),
|
makeAsset(checksum: "b", remoteId: "1-1"),
|
||||||
|
@ -154,7 +157,7 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test efficient sync', () async {
|
test('test efficient sync', () async {
|
||||||
SyncService s = SyncService(db, hs);
|
SyncService s = SyncService(db, hs, albumMediaRepository);
|
||||||
final List<Asset> toUpsert = [
|
final List<Asset> toUpsert = [
|
||||||
makeAsset(checksum: "a", remoteId: "0-1"), // changed
|
makeAsset(checksum: "a", remoteId: "0-1"), // changed
|
||||||
makeAsset(checksum: "f", remoteId: "0-2"), // new
|
makeAsset(checksum: "f", remoteId: "0-2"), // new
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
import 'package:immich_mobile/interfaces/album.interface.dart';
|
import 'package:immich_mobile/interfaces/album.interface.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/album_media.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/asset.interface.dart';
|
import 'package:immich_mobile/interfaces/asset.interface.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/asset_media.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/backup.interface.dart';
|
import 'package:immich_mobile/interfaces/backup.interface.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/file_media.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/user.interface.dart';
|
import 'package:immich_mobile/interfaces/user.interface.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
@ -11,3 +14,9 @@ class MockAssetRepository extends Mock implements IAssetRepository {}
|
||||||
class MockUserRepository extends Mock implements IUserRepository {}
|
class MockUserRepository extends Mock implements IUserRepository {}
|
||||||
|
|
||||||
class MockBackupRepository extends Mock implements IBackupRepository {}
|
class MockBackupRepository extends Mock implements IBackupRepository {}
|
||||||
|
|
||||||
|
class MockAlbumMediaRepository extends Mock implements IAlbumMediaRepository {}
|
||||||
|
|
||||||
|
class MockAssetMediaRepository extends Mock implements IAssetMediaRepository {}
|
||||||
|
|
||||||
|
class MockFileMediaRepository extends Mock implements IFileMediaRepository {}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
|
import 'package:immich_mobile/services/hash.service.dart';
|
||||||
import 'package:immich_mobile/services/sync.service.dart';
|
import 'package:immich_mobile/services/sync.service.dart';
|
||||||
import 'package:immich_mobile/services/user.service.dart';
|
import 'package:immich_mobile/services/user.service.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
@ -8,3 +9,5 @@ class MockApiService extends Mock implements ApiService {}
|
||||||
class MockUserService extends Mock implements UserService {}
|
class MockUserService extends Mock implements UserService {}
|
||||||
|
|
||||||
class MockSyncService extends Mock implements SyncService {}
|
class MockSyncService extends Mock implements SyncService {}
|
||||||
|
|
||||||
|
class MockHashService extends Mock implements HashService {}
|
||||||
|
|
|
@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
||||||
import 'package:immich_mobile/services/album.service.dart';
|
import 'package:immich_mobile/services/album.service.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
import '../fixtures/album.stub.dart';
|
||||||
import '../repository.mocks.dart';
|
import '../repository.mocks.dart';
|
||||||
import '../service.mocks.dart';
|
import '../service.mocks.dart';
|
||||||
|
|
||||||
|
@ -14,6 +15,7 @@ void main() {
|
||||||
late MockAssetRepository assetRepository;
|
late MockAssetRepository assetRepository;
|
||||||
late MockUserRepository userRepository;
|
late MockUserRepository userRepository;
|
||||||
late MockBackupRepository backupRepository;
|
late MockBackupRepository backupRepository;
|
||||||
|
late MockAlbumMediaRepository albumMediaRepository;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
apiService = MockApiService();
|
apiService = MockApiService();
|
||||||
|
@ -23,6 +25,7 @@ void main() {
|
||||||
assetRepository = MockAssetRepository();
|
assetRepository = MockAssetRepository();
|
||||||
userRepository = MockUserRepository();
|
userRepository = MockUserRepository();
|
||||||
backupRepository = MockBackupRepository();
|
backupRepository = MockBackupRepository();
|
||||||
|
albumMediaRepository = MockAlbumMediaRepository();
|
||||||
|
|
||||||
sut = AlbumService(
|
sut = AlbumService(
|
||||||
apiService,
|
apiService,
|
||||||
|
@ -32,6 +35,7 @@ void main() {
|
||||||
assetRepository,
|
assetRepository,
|
||||||
userRepository,
|
userRepository,
|
||||||
backupRepository,
|
backupRepository,
|
||||||
|
albumMediaRepository,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -48,5 +52,22 @@ void main() {
|
||||||
expect(result, false);
|
expect(result, false);
|
||||||
verify(() => syncService.removeAllLocalAlbumsAndAssets());
|
verify(() => syncService.removeAllLocalAlbumsAndAssets());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('one selected albums, two on device', () async {
|
||||||
|
when(() => backupRepository.getIdsBySelection(BackupSelection.exclude))
|
||||||
|
.thenAnswer((_) async => []);
|
||||||
|
when(() => backupRepository.getIdsBySelection(BackupSelection.select))
|
||||||
|
.thenAnswer((_) async => [AlbumStub.oneAsset.localId!]);
|
||||||
|
when(() => albumMediaRepository.getAll())
|
||||||
|
.thenAnswer((_) async => [AlbumStub.oneAsset, AlbumStub.twoAsset]);
|
||||||
|
when(() => syncService.syncLocalAlbumAssetsToDb(any(), any()))
|
||||||
|
.thenAnswer((_) async => true);
|
||||||
|
final result = await sut.refreshDeviceAlbums();
|
||||||
|
expect(result, true);
|
||||||
|
verify(
|
||||||
|
() => syncService.syncLocalAlbumAssetsToDb([AlbumStub.oneAsset], null),
|
||||||
|
).called(1);
|
||||||
|
verifyNoMoreInteractions(syncService);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
Loading…
Reference in a new issue