Compare commits
401 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38503e8673 | ||
|
|
c5319f2ba8 | ||
|
|
5d851d8f90 | ||
|
|
dd76317bb0 | ||
|
|
5519981f9c | ||
|
|
1c249560c0 | ||
|
|
130cc9137c | ||
|
|
95f2d282b5 | ||
|
|
e6668606db | ||
|
|
316c767ffa | ||
|
|
c08b0f7aa4 | ||
|
|
b5a4bcf98f | ||
|
|
dc06a2e193 | ||
|
|
c29446e00a | ||
|
|
5aa36921d8 | ||
|
|
3b3af65d74 | ||
|
|
9ad88d3908 | ||
|
|
012e9091f0 | ||
|
|
8ff0f237e7 | ||
|
|
c80e4a65b8 | ||
|
|
c317f2494f | ||
|
|
264b0b151a | ||
|
|
e5cb3e7005 | ||
|
|
adc1939638 | ||
|
|
2cc1c36b7b | ||
|
|
b99450615e | ||
|
|
5de1f0c93b | ||
|
|
ee902a5ccb | ||
|
|
9e56afaa73 | ||
|
|
77f8c3f712 | ||
|
|
f7a0a414db | ||
|
|
bd49b8464b | ||
|
|
0cbfa319ba | ||
|
|
95e05f167c | ||
|
|
84ef4c2d0b | ||
|
|
53c14dff01 | ||
|
|
02ef29fd8f | ||
|
|
1848f1a94b | ||
|
|
276c38236f | ||
|
|
5e3e3b4427 | ||
|
|
b8dab3ea1c | ||
|
|
2dc32f5339 | ||
|
|
9fd6bc4974 | ||
|
|
3740e9e29d | ||
|
|
118dde38a2 | ||
|
|
cd15fe86c6 | ||
|
|
693a40cc24 | ||
|
|
2cfa8497da | ||
|
|
3f6b9d7ae6 | ||
|
|
e71b6dbb63 | ||
|
|
44722c40c2 | ||
|
|
bfd13b99d2 | ||
|
|
e82d878fa8 | ||
|
|
0587813c70 | ||
|
|
b7712a7c51 | ||
|
|
213f3cd122 | ||
|
|
d103274da5 | ||
|
|
28b244ebc5 | ||
|
|
7d24dea7bc | ||
|
|
4a358a7793 | ||
|
|
699249cd26 | ||
|
|
7125ac7419 | ||
|
|
de67b6e9fc | ||
|
|
39868de5d9 | ||
|
|
751550d469 | ||
|
|
6f0034dd9d | ||
|
|
7bd6180ebf | ||
|
|
5a3ae5f97c | ||
|
|
e447058573 | ||
|
|
8a7e8b8237 | ||
|
|
ed7b1be591 | ||
|
|
9a5ef8b634 | ||
|
|
c2668387b5 | ||
|
|
d265cf62c7 | ||
|
|
a67c3b0624 | ||
|
|
306c78fbb0 | ||
|
|
e4f9dd6b33 | ||
|
|
5fec59f0fe | ||
|
|
53050ca25e | ||
|
|
926aa42168 | ||
|
|
f4f8141b99 | ||
|
|
3d044dd3d4 | ||
|
|
8699ad2f01 | ||
|
|
01aad531f1 | ||
|
|
6340d9f646 | ||
|
|
0f944b1796 | ||
|
|
1d8533168f | ||
|
|
2fca1ba534 | ||
|
|
2d386c769b | ||
|
|
1b41d81839 | ||
|
|
487156c7df | ||
|
|
35c54111e7 | ||
|
|
d71340fbdd | ||
|
|
f32ea85ee2 | ||
|
|
6397ab888a | ||
|
|
5a73043b63 | ||
|
|
f258c40e98 | ||
|
|
36b6476049 | ||
|
|
c4c5ea150f | ||
|
|
1956b3788b | ||
|
|
72cbddff6d | ||
|
|
17670d5538 | ||
|
|
8cfd80663e | ||
|
|
f285e2d706 | ||
|
|
f60e074dd5 | ||
|
|
140eae6859 | ||
|
|
23ee022472 | ||
|
|
b1cf3f9fb0 | ||
|
|
dbbb80a817 | ||
|
|
91a10634cc | ||
|
|
0a2c230254 | ||
|
|
6745b110df | ||
|
|
7061161181 | ||
|
|
f0026f0a81 | ||
|
|
acec985bcb | ||
|
|
c8103a9e06 | ||
|
|
02f64ad45f | ||
|
|
d0931d1d0e | ||
|
|
5c78de5355 | ||
|
|
1aa9f61419 | ||
|
|
0f2b51d1a5 | ||
|
|
9fef560d15 | ||
|
|
09c7bfd717 | ||
|
|
2ff059a701 | ||
|
|
70b043d34a | ||
|
|
15a00379b5 | ||
|
|
7246ade2ae | ||
|
|
7cad0a83d2 | ||
|
|
e6703aef65 | ||
|
|
662dfad7ca | ||
|
|
466ab30f8b | ||
|
|
12c1845a5f | ||
|
|
2d0202df36 | ||
|
|
2ec4f5a7e5 | ||
|
|
6723bed1b0 | ||
|
|
84e8ce519e | ||
|
|
1b50528181 | ||
|
|
08dff77ad4 | ||
|
|
713abce89a | ||
|
|
da15593a47 | ||
|
|
fc31cc61d1 | ||
|
|
9c6259b713 | ||
|
|
ed603232a5 | ||
|
|
ccd89d3451 | ||
|
|
647b2ef4a7 | ||
|
|
f54c79462e | ||
|
|
eb81d96ddf | ||
|
|
57363a24ef | ||
|
|
9431995e8c | ||
|
|
64b86376f6 | ||
|
|
a825367c49 | ||
|
|
1ffbb27ac5 | ||
|
|
d4add9f7ef | ||
|
|
541494613f | ||
|
|
2b3427e40b | ||
|
|
a57c9e881d | ||
|
|
d15f1e15ce | ||
|
|
0411f8ad40 | ||
|
|
2981816c90 | ||
|
|
a6de98ef68 | ||
|
|
18156ce8bc | ||
|
|
458c1cf86d | ||
|
|
90c0874608 | ||
|
|
928ffba4d7 | ||
|
|
0701212540 | ||
|
|
347bf4d2e0 | ||
|
|
2729edfded | ||
|
|
4e8d2c5cea | ||
|
|
84e9336672 | ||
|
|
cecdea3f93 | ||
|
|
37674deba0 | ||
|
|
733be57df8 | ||
|
|
74df52baf1 | ||
|
|
b817c4475e | ||
|
|
f95dac31d2 | ||
|
|
151289b24a | ||
|
|
5dac9d4dd6 | ||
|
|
43b9dbdc54 | ||
|
|
9d3caaa5d5 | ||
|
|
7eda2ed24e | ||
|
|
30df5271b4 | ||
|
|
4b1f7612a3 | ||
|
|
bf6521e8d5 | ||
|
|
4bac1bcb1d | ||
|
|
b123635584 | ||
|
|
d815143bb4 | ||
|
|
ff6228497f | ||
|
|
7469578e77 | ||
|
|
76afef6149 | ||
|
|
2b3178495a | ||
|
|
d6f3ff8db3 | ||
|
|
7b0ef2b0c0 | ||
|
|
35f95010ea | ||
|
|
233f0ec1e1 | ||
|
|
64820ff5fa | ||
|
|
86ffd4e1e6 | ||
|
|
436a02d352 | ||
|
|
0f3b8bae48 | ||
|
|
0f270a379f | ||
|
|
609f6b8e18 | ||
|
|
7896b397c2 | ||
|
|
097078bd24 | ||
|
|
e43e3c4230 | ||
|
|
439f1ff0fb | ||
|
|
e5d78cfd99 | ||
|
|
bd76e66abf | ||
|
|
9e6d7908a9 | ||
|
|
281735e172 | ||
|
|
bc93aca110 | ||
|
|
df6e409ca1 | ||
|
|
7489821434 | ||
|
|
52e0f04ec2 | ||
|
|
ad88ce632c | ||
|
|
26222ec836 | ||
|
|
beeaee4fd9 | ||
|
|
dc98c7bcf5 | ||
|
|
82497563c2 | ||
|
|
4a9b4520d2 | ||
|
|
756c8e5b7d | ||
|
|
dffb920cab | ||
|
|
fa7ddbba0c | ||
|
|
13a068969c | ||
|
|
717e8c8b7e | ||
|
|
015adb595c | ||
|
|
91635d2e7d | ||
|
|
cd3499a004 | ||
|
|
92a964cda6 | ||
|
|
a9ba615962 | ||
|
|
f2b0c11622 | ||
|
|
83b89b6bbf | ||
|
|
ee0a858302 | ||
|
|
b34c923a66 | ||
|
|
fd927d038b | ||
|
|
64e9902f57 | ||
|
|
c3af79d113 | ||
|
|
d87e679650 | ||
|
|
7e2242dc69 | ||
|
|
9adc207b02 | ||
|
|
36049f6633 | ||
|
|
9341bc95ee | ||
|
|
252dca1a01 | ||
|
|
70501054d2 | ||
|
|
be012e0a28 | ||
|
|
340a0c097f | ||
|
|
7fc8649455 | ||
|
|
c97a313edb | ||
|
|
480fdc84dc | ||
|
|
c92ef45c9a | ||
|
|
ca62012a6f | ||
|
|
151a0d13a4 | ||
|
|
747b1b84c6 | ||
|
|
e060fb9823 | ||
|
|
cd377149bc | ||
|
|
d9e22a489b | ||
|
|
524db74bf5 | ||
|
|
e1222d51a9 | ||
|
|
1c68f0bb60 | ||
|
|
f6419caf5c | ||
|
|
441bcbd187 | ||
|
|
4ad3927348 | ||
|
|
4ae15e5966 | ||
|
|
d67d1d3df8 | ||
|
|
07e1d33ca8 | ||
|
|
50e15fa56c | ||
|
|
3f262c5ba2 | ||
|
|
7f34870e3a | ||
|
|
8ba5013926 | ||
|
|
e9a24efecb | ||
|
|
eaf74e4059 | ||
|
|
e9e1c3ca27 | ||
|
|
eb34533aed | ||
|
|
cd042e741e | ||
|
|
6e944b0b55 | ||
|
|
18b6b499dd | ||
|
|
9205ef8219 | ||
|
|
1ecdbdb88e | ||
|
|
e2bb4d723e | ||
|
|
cfe32c47f0 | ||
|
|
a7f6b6589d | ||
|
|
715e305e09 | ||
|
|
f7330be52c | ||
|
|
4cce54a0c6 | ||
|
|
1850e9a2a6 | ||
|
|
21e2b589cc | ||
|
|
b7f8deb452 | ||
|
|
b6ffb3ca22 | ||
|
|
9351a52800 | ||
|
|
c7510024c0 | ||
|
|
63d3b1c94b | ||
|
|
cec27b40a4 | ||
|
|
2f8d0d1957 | ||
|
|
7f0a36f110 | ||
|
|
355367a601 | ||
|
|
c66da422cd | ||
|
|
6f90fad4a2 | ||
|
|
bcd6f55376 | ||
|
|
b2766a0d4f | ||
|
|
ec1b95b0cd | ||
|
|
4369317a4d | ||
|
|
b18298dc62 | ||
|
|
b3e467a1a4 | ||
|
|
f8cd3d9fb4 | ||
|
|
6d4756ca4b | ||
|
|
676bbb4d88 | ||
|
|
da8edfd34e | ||
|
|
bf453cfaac | ||
|
|
35f41f044e | ||
|
|
acff269695 | ||
|
|
2b4f96dbb7 | ||
|
|
d5796e2abb | ||
|
|
afe9690891 | ||
|
|
fc619bbd03 | ||
|
|
a198331ffd | ||
|
|
c58fe5358d | ||
|
|
defc5164b9 | ||
|
|
97d4fb0693 | ||
|
|
e708564cb9 | ||
|
|
92068e026b | ||
|
|
8a079ab4f4 | ||
|
|
b41c57cb8d | ||
|
|
87167e49fc | ||
|
|
089d1dcd10 | ||
|
|
db02e66124 | ||
|
|
964066bf31 | ||
|
|
643220e595 | ||
|
|
e871498161 | ||
|
|
31a88b74df | ||
|
|
1801258fea | ||
|
|
27acc2125b | ||
|
|
60b7a91756 | ||
|
|
b38a01820d | ||
|
|
347d5a7a72 | ||
|
|
82979ac729 | ||
|
|
5768eeb1fe | ||
|
|
7a9ff9877a | ||
|
|
3565540a61 | ||
|
|
06d78e5d6a | ||
|
|
6c11b76c11 | ||
|
|
749cfde7d8 | ||
|
|
6bcaa8ae26 | ||
|
|
99c6318b0e | ||
|
|
abf789a4aa | ||
|
|
f5d3712cbb | ||
|
|
45dd540abc | ||
|
|
865a736bdd | ||
|
|
75bd1bfef6 | ||
|
|
4cf67fe171 | ||
|
|
65895328dc | ||
|
|
a1c20b9c8a | ||
|
|
de9def5370 | ||
|
|
9cc723a280 | ||
|
|
16beae2a82 | ||
|
|
2e25c38324 | ||
|
|
7e691f84e4 | ||
|
|
d99593fc85 | ||
|
|
d4877ea446 | ||
|
|
5f4c748886 | ||
|
|
eaab58c62a | ||
|
|
c1c020402e | ||
|
|
028f4e61d2 | ||
|
|
abc6f56247 | ||
|
|
822eb59761 | ||
|
|
fade7859ab | ||
|
|
f85047fb28 | ||
|
|
3f81c9beae | ||
|
|
b7fa8d7c89 | ||
|
|
5f75c5fc3f | ||
|
|
98eaee3b9e | ||
|
|
59bd039bed | ||
|
|
3e90126a55 | ||
|
|
dabcc0aeb5 | ||
|
|
c4b99af0e2 | ||
|
|
679c12bb90 | ||
|
|
3ef8ece8c0 | ||
|
|
24f8cf188a | ||
|
|
233838da3e | ||
|
|
06c4866c75 | ||
|
|
fa71acf91a | ||
|
|
81d40826b3 | ||
|
|
2a14b5e5a3 | ||
|
|
63bbca09f3 | ||
|
|
cbff68bc42 | ||
|
|
070ab80be9 | ||
|
|
0efbf407d3 | ||
|
|
ab22b28695 | ||
|
|
d3466d7efe | ||
|
|
5945f2aaad | ||
|
|
4c79b9cb92 | ||
|
|
4676c363d2 | ||
|
|
f75807d8f0 | ||
|
|
578541308a | ||
|
|
30cded4d3d | ||
|
|
be2aee6baa | ||
|
|
fae23df6eb | ||
|
|
2a1f2aded1 | ||
|
|
6e85d24286 | ||
|
|
0bcc676e44 | ||
|
|
b5a9bab5c6 | ||
|
|
5849d14cd9 | ||
|
|
77cde87927 | ||
|
|
670d6e8470 |
@@ -1,3 +1,3 @@
|
||||
description: This file stores settings for Dart & Flutter DevTools.
|
||||
=description: This file stores settings for Dart & Flutter DevTools.
|
||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||
extensions:
|
||||
|
||||
@@ -181,6 +181,8 @@ PODS:
|
||||
- PromisesObjC (2.4.0)
|
||||
- receive_sharing_intent (1.8.1):
|
||||
- Flutter
|
||||
- rive_common (0.0.1):
|
||||
- Flutter
|
||||
- rust_lib_photos (0.0.1):
|
||||
- Flutter
|
||||
- SDWebImage (5.21.1):
|
||||
@@ -291,6 +293,7 @@ DEPENDENCIES:
|
||||
- photo_manager (from `.symlinks/plugins/photo_manager/ios`)
|
||||
- privacy_screen (from `.symlinks/plugins/privacy_screen/ios`)
|
||||
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
|
||||
- rive_common (from `.symlinks/plugins/rive_common/ios`)
|
||||
- rust_lib_photos (from `.symlinks/plugins/rust_lib_photos/ios`)
|
||||
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
@@ -309,7 +312,7 @@ DEPENDENCIES:
|
||||
- workmanager (from `.symlinks/plugins/workmanager/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
https://github.com/ente-io/ffmpeg-kit-custom-repo-ios:
|
||||
https://github.com/ente-io/ffmpeg-kit-custom-repo-ios.git:
|
||||
- ffmpeg_kit_custom
|
||||
trunk:
|
||||
- Firebase
|
||||
@@ -418,6 +421,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/privacy_screen/ios"
|
||||
receive_sharing_intent:
|
||||
:path: ".symlinks/plugins/receive_sharing_intent/ios"
|
||||
rive_common:
|
||||
:path: ".symlinks/plugins/rive_common/ios"
|
||||
rust_lib_photos:
|
||||
:path: ".symlinks/plugins/rust_lib_photos/ios"
|
||||
sentry_flutter:
|
||||
@@ -452,84 +457,85 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/workmanager/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7
|
||||
battery_info: 83f3aae7be2fccefab1d2bf06b8aa96f11c8bcdd
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
|
||||
dart_ui_isolate: 46f6714abe6891313267153ef6f9748d8ecfcab1
|
||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||
emoji_picker_flutter: ed468d9746c21711e66b2788880519a9de5de211
|
||||
app_links: f3e17e4ee5e357b39d8b95290a9b2c299fca71c6
|
||||
battery_info: b6c551049266af31556b93c9d9b9452cfec0219f
|
||||
connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d
|
||||
cupertino_http: 947a233f40cfea55167a49f2facc18434ea117ba
|
||||
dart_ui_isolate: d5bcda83ca4b04f129d70eb90110b7a567aece14
|
||||
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
|
||||
emoji_picker_flutter: fe2e6151c5b548e975d546e6eeb567daf0962a58
|
||||
ffmpeg_kit_custom: 682b4f2f1ff1f8abae5a92f6c3540f2441d5be99
|
||||
ffmpeg_kit_flutter: 915b345acc97d4142e8a9a8549d177ff10f043f5
|
||||
file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6
|
||||
ffmpeg_kit_flutter: 9dce4803991478c78c6fb9f972703301101095fe
|
||||
file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
|
||||
Firebase: d99ac19b909cd2c548339c2241ecd0d1599ab02e
|
||||
firebase_core: ece862f94b2bc72ee0edbeec7ab5c7cb09fe1ab5
|
||||
firebase_messaging: e1a5fae495603115be1d0183bc849da748734e2b
|
||||
firebase_core: cf4d42a8ac915e51c0c2dc103442f3036d941a2d
|
||||
firebase_messaging: fee490327c1aae28a0da1e65fca856547deca493
|
||||
FirebaseCore: efb3893e5b94f32b86e331e3bd6dadf18b66568e
|
||||
FirebaseCoreInternal: 9afa45b1159304c963da48addb78275ef701c6b4
|
||||
FirebaseInstallations: 317270fec08a5d418fdbc8429282238cab3ac843
|
||||
FirebaseMessaging: 3b26e2cee503815e01c3701236b020aa9b576f09
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
flutter_email_sender: aa1e9772696691d02cd91fea829856c11efb8e58
|
||||
flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1
|
||||
flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99
|
||||
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
|
||||
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
||||
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
||||
flutter_sodium: 7e4621538491834eba53bd524547854bcbbd6987
|
||||
flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544
|
||||
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
|
||||
flutter_email_sender: e03bdda7637bcd3539bfe718fddd980e9508efaa
|
||||
flutter_image_compress_common: ec1d45c362c9d30a3f6a0426c297f47c52007e3e
|
||||
flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4
|
||||
flutter_local_notifications: ff50f8405aaa0ccdc7dcfb9022ca192e8ad9688f
|
||||
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
|
||||
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
|
||||
flutter_sodium: a00383520fc689c688b66fd3092984174712493e
|
||||
flutter_timezone: ac3da59ac941ff1c98a2e1f0293420e020120282
|
||||
fluttertoast: 21eecd6935e7064cc1fcb733a4c5a428f3f24f0f
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
|
||||
in_app_purchase_storekit: d1a48cb0f8b29dbf5f85f782f5dd79b21b90a5e6
|
||||
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
|
||||
launcher_icon_switcher: 84c218d233505aa7d8655d8fa61a3ba802c022da
|
||||
home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57
|
||||
in_app_purchase_storekit: a1ce04056e23eecc666b086040239da7619cd783
|
||||
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
|
||||
launcher_icon_switcher: 8e0ad2131a20c51c1dd939896ee32e70cd845b37
|
||||
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
||||
local_auth_ios: f7a1841beef3151d140a967c2e46f30637cdf451
|
||||
local_auth_ios: 5046a18c018dd973247a0564496c8898dbb5adf9
|
||||
Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d
|
||||
maps_launcher: edf829809ba9e894d70e569bab11c16352dedb45
|
||||
media_extension: 671e2567880d96c95c65c9a82ccceed8f2e309fd
|
||||
media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854
|
||||
media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474
|
||||
motion_sensors: 741e702c17467b9569a92165dda8d4d88c6167f1
|
||||
motionphoto: 23e2aeb5c6380112f69468d71f970fa7438e5ed1
|
||||
move_to_background: 7e3467dd2a1d1013e98c9c1cb93fd53cd7ef9d84
|
||||
maps_launcher: 2e5b6a2d664ec6c27f82ffa81b74228d770ab203
|
||||
media_extension: 6618f07abd762cdbfaadf1b0c56a287e820f0c84
|
||||
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
|
||||
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
|
||||
motion_sensors: 03f55b7c637a7e365a0b5f9697a449f9059d5d91
|
||||
motionphoto: 8b65ce50c7d7ff3c767534fc3768b2eed9ac24e4
|
||||
move_to_background: cd3091014529ec7829e342ad2d75c0a11f4378a5
|
||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||
native_video_player: 6809dec117e8997161dbfb42a6f90d6df71a504d
|
||||
objective_c: 89e720c30d716b036faf9c9684022048eee1eee2
|
||||
onnxruntime: f9b296392c96c42882be020a59dbeac6310d81b2
|
||||
native_video_player: 29ab24a926804ac8c4a57eb6d744c7d927c2bc3e
|
||||
objective_c: 77e887b5ba1827970907e10e832eec1683f3431d
|
||||
onnxruntime: e7c2ae44385191eaad5ae64c935a72debaddc997
|
||||
onnxruntime-c: a909204639a1f035f575127ac406f781ac797c9c
|
||||
onnxruntime-objc: b6fab0f1787aa6f7190c2013f03037df4718bd8b
|
||||
open_mail_app: 7314a609e88eed22d53671279e189af7a0ab0f11
|
||||
open_mail_app: 70273c53f768beefdafbe310c3d9086e4da3cb02
|
||||
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
photo_manager: 1d80ae07a89a67dfbcae95953a1e5a24af7c3e62
|
||||
privacy_screen: 3159a541f5d3a31bea916cfd4e58f9dc722b3fd4
|
||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
photo_manager: 81954a1bf804b6e882d0453b3b6bc7fad7b47d3d
|
||||
privacy_screen: 1a131c052ceb3c3659934b003b0d397c2381a24e
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
|
||||
rust_lib_photos: 04d3901908d2972192944083310b65abf410774c
|
||||
receive_sharing_intent: 79c848f5b045674ad60b9fea3bafea59962ad2c1
|
||||
rive_common: 4743dbfd2911c99066547a3c6454681e0fa907df
|
||||
rust_lib_photos: 8813b31af48ff02ca75520cbc81a363a13d51a84
|
||||
SDWebImage: f29024626962457f3470184232766516dee8dfea
|
||||
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380
|
||||
Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854
|
||||
sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684
|
||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
sentry_flutter: 2df8b0aab7e4aba81261c230cbea31c82a62dd1b
|
||||
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||
sqlite3: 1d85290c3321153511f6e900ede7a1608718bbd5
|
||||
sqlite3_flutter_libs: e7fc8c9ea2200ff3271f08f127842131746b70e2
|
||||
system_info_plus: 555ce7047fbbf29154726db942ae785c29211740
|
||||
thermal: d4c48be750d1ddbab36b0e2dcb2471531bc8df41
|
||||
ua_client_hints: 92fe0d139619b73ec9fcb46cc7e079a26178f586
|
||||
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
|
||||
vibration: 8e2f50fc35bb736f9eecb7dd9f7047fbb6a6e888
|
||||
video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b
|
||||
video_thumbnail: b637e0ad5f588ca9945f6e2c927f73a69a661140
|
||||
volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12
|
||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||
workmanager: b89e4e4445d8b57ee2fdbf1c3925696ebe5b8990
|
||||
sqlite3_flutter_libs: 2c48c4ee7217fd653251975e43412250d5bcbbe2
|
||||
system_info_plus: 5393c8da281d899950d751713575fbf91c7709aa
|
||||
thermal: a9261044101ae8f532fa29cab4e8270b51b3f55c
|
||||
ua_client_hints: aeabd123262c087f0ce151ef96fa3ab77bfc8b38
|
||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||
vibration: 7d883d141656a1c1a6d8d238616b2042a51a1241
|
||||
video_player_avfoundation: 7c6c11d8470e1675df7397027218274b6d2360b3
|
||||
video_thumbnail: c4e2a3c539e247d4de13cd545344fd2d26ffafd1
|
||||
volume_controller: 2e3de73d6e7e81a0067310d17fb70f2f86d71ac7
|
||||
wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e
|
||||
workmanager: 05afacf221f5086e18450250dce57f59bb23e6b0
|
||||
|
||||
PODFILE CHECKSUM: cce2cd3351d3488dca65b151118552b680e23635
|
||||
|
||||
|
||||
@@ -565,6 +565,7 @@
|
||||
"${BUILT_PRODUCTS_DIR}/photo_manager/photo_manager.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/privacy_screen/privacy_screen.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/receive_sharing_intent/receive_sharing_intent.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/rive_common/rive_common.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/rust_lib_photos/rust_lib_photos.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/sentry_flutter/sentry_flutter.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/share_plus/share_plus.framework",
|
||||
@@ -662,6 +663,7 @@
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/photo_manager.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/privacy_screen.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/receive_sharing_intent.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/rive_common.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/rust_lib_photos.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/sentry_flutter.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/share_plus.framework",
|
||||
|
||||
@@ -29,6 +29,10 @@ class LRUMap<K, V> {
|
||||
}
|
||||
}
|
||||
|
||||
bool containsKey(K key) {
|
||||
return _map.containsKey(key);
|
||||
}
|
||||
|
||||
void remove(K key) {
|
||||
_map.remove(key);
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:photos/core/cache/lru_map.dart';
|
||||
import 'package:photos/core/constants.dart';
|
||||
import 'package:photos/models/file/file.dart';
|
||||
|
||||
class ThumbnailInMemoryLruCache {
|
||||
static final LRUMap<String, Uint8List?> _map = LRUMap(1000);
|
||||
|
||||
static Uint8List? get(EnteFile enteFile, [int? size]) {
|
||||
return _map.get(
|
||||
enteFile.cacheKey() +
|
||||
"_" +
|
||||
(size != null ? size.toString() : thumbnailLargeSize.toString()),
|
||||
);
|
||||
}
|
||||
|
||||
static void put(
|
||||
EnteFile enteFile,
|
||||
Uint8List? imageData, [
|
||||
int? size,
|
||||
]) {
|
||||
_map.put(
|
||||
enteFile.cacheKey() +
|
||||
"_" +
|
||||
(size != null ? size.toString() : thumbnailLargeSize.toString()),
|
||||
imageData,
|
||||
);
|
||||
}
|
||||
|
||||
static void clearCache(EnteFile enteFile) {
|
||||
_map.remove(
|
||||
enteFile.cacheKey() + "_" + thumbnailLargeSize.toString(),
|
||||
);
|
||||
_map.remove(
|
||||
enteFile.cacheKey() + "_" + thumbnailSmallSize.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,9 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:photos/core/constants.dart';
|
||||
import 'package:photos/core/error-reporting/super_logging.dart';
|
||||
import 'package:photos/core/event_bus.dart';
|
||||
import 'package:photos/db/collections_db.dart';
|
||||
import 'package:photos/db/files_db.dart';
|
||||
import "package:photos/db/memories_db.dart";
|
||||
import "package:photos/db/ml/db.dart";
|
||||
import 'package:photos/db/trash_db.dart';
|
||||
import 'package:photos/db/upload_locks_db.dart';
|
||||
import "package:photos/events/endpoint_updated_event.dart";
|
||||
import 'package:photos/events/signed_in_event.dart';
|
||||
@@ -23,6 +21,8 @@ import 'package:photos/events/user_logged_out_event.dart';
|
||||
import 'package:photos/models/api/user/key_attributes.dart';
|
||||
import 'package:photos/models/api/user/key_gen_result.dart';
|
||||
import 'package:photos/models/api/user/private_key_attributes.dart';
|
||||
import 'package:photos/module/upload/service/file_uploader.dart';
|
||||
import "package:photos/service_locator.dart";
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import 'package:photos/services/favorites_service.dart';
|
||||
import "package:photos/services/home_widget_service.dart";
|
||||
@@ -31,7 +31,6 @@ import "package:photos/services/machine_learning/face_ml/person/person_service.d
|
||||
import "package:photos/services/machine_learning/similar_images_service.dart";
|
||||
import 'package:photos/services/search_service.dart';
|
||||
import 'package:photos/services/sync/sync_service.dart';
|
||||
import 'package:photos/utils/file_uploader.dart';
|
||||
import "package:photos/utils/lock_screen_settings.dart";
|
||||
import 'package:photos/utils/validator_util.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -194,14 +193,13 @@ class Configuration {
|
||||
_cachedToken = null;
|
||||
_secretKey = null;
|
||||
await FilesDB.instance.clearTable();
|
||||
await CollectionsDB.instance.clearTable();
|
||||
// await CollectionsDB.instance.clearTable();
|
||||
await MemoriesDB.instance.clearTable();
|
||||
await MLDataDB.instance.clearTable();
|
||||
await remoteDB.clearAllTables();
|
||||
await SimilarImagesService.instance.clearCache();
|
||||
|
||||
await UploadLocksDB.instance.clearTable();
|
||||
await IgnoredFilesService.instance.reset();
|
||||
await TrashDB.instance.clearTable();
|
||||
unawaited(HomeWidgetService.instance.clearWidget(autoLogout));
|
||||
if (!autoLogout) {
|
||||
// Following services won't be initialized if it's the case of autoLogout
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import "package:flutter/foundation.dart";
|
||||
|
||||
const int thumbnailSmallSize = 256;
|
||||
const int thumbnailQuality = 50;
|
||||
const int thumbnailLargeSize = 512;
|
||||
const int compressedThumbnailResolution = 1080;
|
||||
const int thumbnailDataLimit = 100 * 1024;
|
||||
// thumbnailSmallSize Thumbnail sizes in pixels 256px
|
||||
const int thumbnailSmall256 = 256;
|
||||
// thumbnailMediumSize Thumbnail sizes in pixels 512px
|
||||
const int thumbnailLarge512 = 512; // 512px
|
||||
const int compressThumb1080 = 1080;
|
||||
const int thumbnailDataMaxSize = 100 * 1024;
|
||||
const String sentryDSN =
|
||||
"https://2235e5c99219488ea93da34b9ac1cb68@sentry.ente.io/4";
|
||||
const String sentryDebugDSN =
|
||||
@@ -109,4 +111,4 @@ final tempDirCleanUpInterval = kDebugMode
|
||||
const kFilterChipHeight = 32.0;
|
||||
const kMaxAppbarFilters = 14;
|
||||
|
||||
const kLivePhotoHashSeparator = ':';
|
||||
const kHashSeprator = ':';
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import "package:photos/models/api/collection/public_url.dart";
|
||||
import "package:photos/models/api/collection/user.dart";
|
||||
import 'package:photos/models/collection/collection.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:sqflite_migration/sqflite_migration.dart';
|
||||
|
||||
class CollectionsDB {
|
||||
static const _databaseName = "ente.collections.db";
|
||||
static const table = 'collections';
|
||||
static const tempTable = 'temp_collections';
|
||||
static const _sqlBoolTrue = 1;
|
||||
static const _sqlBoolFalse = 0;
|
||||
|
||||
static const columnID = 'collection_id';
|
||||
static const columnOwner = 'owner';
|
||||
static const columnEncryptedKey = 'encrypted_key';
|
||||
static const columnKeyDecryptionNonce = 'key_decryption_nonce';
|
||||
static const columnName = 'name';
|
||||
static const columnEncryptedName = 'encrypted_name';
|
||||
static const columnNameDecryptionNonce = 'name_decryption_nonce';
|
||||
static const columnType = 'type';
|
||||
static const columnEncryptedPath = 'encrypted_path';
|
||||
static const columnPathDecryptionNonce = 'path_decryption_nonce';
|
||||
static const columnVersion = 'version';
|
||||
static const columnSharees = 'sharees';
|
||||
static const columnPublicURLs = 'public_urls';
|
||||
// MMD -> Magic Metadata
|
||||
static const columnMMdEncodedJson = 'mmd_encoded_json';
|
||||
static const columnMMdVersion = 'mmd_ver';
|
||||
|
||||
static const columnPubMMdEncodedJson = 'pub_mmd_encoded_json';
|
||||
static const columnPubMMdVersion = 'pub_mmd_ver';
|
||||
|
||||
static const columnSharedMMdJson = 'shared_mmd_json';
|
||||
static const columnSharedMMdVersion = 'shared_mmd_ver';
|
||||
|
||||
static const columnUpdationTime = 'updation_time';
|
||||
static const columnIsDeleted = 'is_deleted';
|
||||
|
||||
static final intitialScript = [...createTable(table)];
|
||||
static final migrationScripts = [
|
||||
...alterNameToAllowNULL(),
|
||||
...addEncryptedName(),
|
||||
...addVersion(),
|
||||
...addIsDeleted(),
|
||||
...addPublicURLs(),
|
||||
...addPrivateMetadata(),
|
||||
...addPublicMetadata(),
|
||||
...addShareeMetadata(),
|
||||
];
|
||||
|
||||
final dbConfig = MigrationConfig(
|
||||
initializationScript: intitialScript,
|
||||
migrationScripts: migrationScripts,
|
||||
);
|
||||
|
||||
CollectionsDB._privateConstructor();
|
||||
|
||||
static final CollectionsDB instance = CollectionsDB._privateConstructor();
|
||||
|
||||
static Future<Database>? _dbFuture;
|
||||
|
||||
Future<Database> get database async {
|
||||
_dbFuture ??= _initDatabase();
|
||||
return _dbFuture!;
|
||||
}
|
||||
|
||||
Future<Database> _initDatabase() async {
|
||||
final Directory documentsDirectory =
|
||||
await getApplicationDocumentsDirectory();
|
||||
final String path = join(documentsDirectory.path, _databaseName);
|
||||
return await openDatabaseWithMigration(path, dbConfig);
|
||||
}
|
||||
|
||||
Future<void> clearTable() async {
|
||||
final db = await instance.database;
|
||||
await db.delete(table);
|
||||
}
|
||||
|
||||
static List<String> createTable(String tableName) {
|
||||
return [
|
||||
'''
|
||||
CREATE TABLE $tableName (
|
||||
$columnID INTEGER PRIMARY KEY NOT NULL,
|
||||
$columnOwner TEXT NOT NULL,
|
||||
$columnEncryptedKey TEXT NOT NULL,
|
||||
$columnKeyDecryptionNonce TEXT,
|
||||
$columnName TEXT,
|
||||
$columnType TEXT NOT NULL,
|
||||
$columnEncryptedPath TEXT,
|
||||
$columnPathDecryptionNonce TEXT,
|
||||
$columnSharees TEXT,
|
||||
$columnUpdationTime TEXT NOT NULL
|
||||
);
|
||||
'''
|
||||
];
|
||||
}
|
||||
|
||||
static List<String> alterNameToAllowNULL() {
|
||||
return [
|
||||
...createTable(tempTable),
|
||||
'''
|
||||
INSERT INTO $tempTable
|
||||
SELECT *
|
||||
FROM $table;
|
||||
|
||||
DROP TABLE $table;
|
||||
|
||||
ALTER TABLE $tempTable
|
||||
RENAME TO $table;
|
||||
'''
|
||||
];
|
||||
}
|
||||
|
||||
static List<String> addEncryptedName() {
|
||||
return [
|
||||
'''
|
||||
ALTER TABLE $table
|
||||
ADD COLUMN $columnEncryptedName TEXT;
|
||||
''',
|
||||
'''ALTER TABLE $table
|
||||
ADD COLUMN $columnNameDecryptionNonce TEXT;
|
||||
'''
|
||||
];
|
||||
}
|
||||
|
||||
static List<String> addVersion() {
|
||||
return [
|
||||
'''
|
||||
ALTER TABLE $table
|
||||
ADD COLUMN $columnVersion INTEGER DEFAULT 0;
|
||||
'''
|
||||
];
|
||||
}
|
||||
|
||||
static List<String> addIsDeleted() {
|
||||
return [
|
||||
'''
|
||||
ALTER TABLE $table
|
||||
ADD COLUMN $columnIsDeleted INTEGER DEFAULT $_sqlBoolFalse;
|
||||
'''
|
||||
];
|
||||
}
|
||||
|
||||
static List<String> addPublicURLs() {
|
||||
return [
|
||||
'''
|
||||
ALTER TABLE $table
|
||||
ADD COLUMN $columnPublicURLs TEXT;
|
||||
'''
|
||||
];
|
||||
}
|
||||
|
||||
static List<String> addPrivateMetadata() {
|
||||
return [
|
||||
'''
|
||||
ALTER TABLE $table ADD COLUMN $columnMMdEncodedJson TEXT DEFAULT '{}';
|
||||
''',
|
||||
'''
|
||||
ALTER TABLE $table ADD COLUMN $columnMMdVersion INTEGER DEFAULT 0;
|
||||
'''
|
||||
];
|
||||
}
|
||||
|
||||
static List<String> addPublicMetadata() {
|
||||
return [
|
||||
'''
|
||||
ALTER TABLE $table ADD COLUMN $columnPubMMdEncodedJson TEXT DEFAULT '
|
||||
{}';
|
||||
''',
|
||||
'''
|
||||
ALTER TABLE $table ADD COLUMN $columnPubMMdVersion INTEGER DEFAULT 0;
|
||||
'''
|
||||
];
|
||||
}
|
||||
|
||||
static List<String> addShareeMetadata() {
|
||||
return [
|
||||
'''
|
||||
ALTER TABLE $table ADD COLUMN $columnSharedMMdJson TEXT DEFAULT '
|
||||
{}';
|
||||
''',
|
||||
'''
|
||||
ALTER TABLE $table ADD COLUMN $columnSharedMMdVersion INTEGER DEFAULT 0;
|
||||
'''
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> insert(List<Collection> collections) async {
|
||||
final db = await instance.database;
|
||||
var batch = db.batch();
|
||||
int batchCounter = 0;
|
||||
for (final collection in collections) {
|
||||
if (batchCounter == 400) {
|
||||
await batch.commit(noResult: true);
|
||||
batch = db.batch();
|
||||
batchCounter = 0;
|
||||
}
|
||||
batch.insert(
|
||||
table,
|
||||
_getRowForCollection(collection),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
batchCounter++;
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
Future<List<Collection>> getAllCollections() async {
|
||||
final db = await instance.database;
|
||||
final rows = await db.query(table);
|
||||
final collections = <Collection>[];
|
||||
for (final row in rows) {
|
||||
collections.add(_convertToCollection(row));
|
||||
}
|
||||
return collections;
|
||||
}
|
||||
|
||||
// getActiveCollectionIDsAndUpdationTime returns map of collectionID to
|
||||
// updationTime for non-deleted collections
|
||||
Future<Map<int, int>> getActiveIDsAndRemoteUpdateTime() async {
|
||||
final db = await instance.database;
|
||||
final rows = await db.query(
|
||||
table,
|
||||
where: '($columnIsDeleted = ? OR $columnIsDeleted IS NULL)',
|
||||
whereArgs: [_sqlBoolFalse],
|
||||
columns: [columnID, columnUpdationTime],
|
||||
);
|
||||
final collectionIDsAndUpdationTime = <int, int>{};
|
||||
for (final row in rows) {
|
||||
collectionIDsAndUpdationTime[row[columnID] as int] =
|
||||
int.parse(row[columnUpdationTime] as String);
|
||||
}
|
||||
return collectionIDsAndUpdationTime;
|
||||
}
|
||||
|
||||
Future<int> deleteCollection(int collectionID) async {
|
||||
final db = await instance.database;
|
||||
return db.delete(
|
||||
table,
|
||||
where: '$columnID = ?',
|
||||
whereArgs: [collectionID],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _getRowForCollection(Collection collection) {
|
||||
final row = <String, dynamic>{};
|
||||
row[columnID] = collection.id;
|
||||
row[columnOwner] = collection.owner.toJson();
|
||||
row[columnEncryptedKey] = collection.encryptedKey;
|
||||
row[columnKeyDecryptionNonce] = collection.keyDecryptionNonce;
|
||||
// ignore: deprecated_member_use_from_same_package
|
||||
row[columnName] = collection.name;
|
||||
row[columnEncryptedName] = collection.encryptedName;
|
||||
row[columnNameDecryptionNonce] = collection.nameDecryptionNonce;
|
||||
row[columnType] = typeToString(collection.type);
|
||||
row[columnEncryptedPath] = collection.attributes.encryptedPath;
|
||||
row[columnPathDecryptionNonce] = collection.attributes.pathDecryptionNonce;
|
||||
row[columnVersion] = collection.attributes.version;
|
||||
row[columnSharees] =
|
||||
json.encode(collection.sharees.map((x) => x.toMap()).toList());
|
||||
row[columnPublicURLs] =
|
||||
json.encode(collection.publicURLs.map((x) => x.toMap()).toList());
|
||||
row[columnUpdationTime] = collection.updationTime;
|
||||
if (collection.isDeleted) {
|
||||
row[columnIsDeleted] = _sqlBoolTrue;
|
||||
} else {
|
||||
row[columnIsDeleted] = _sqlBoolFalse;
|
||||
}
|
||||
row[columnMMdVersion] = collection.mMdVersion;
|
||||
row[columnMMdEncodedJson] = collection.mMdEncodedJson ?? '{}';
|
||||
row[columnPubMMdVersion] = collection.mMbPubVersion;
|
||||
row[columnPubMMdEncodedJson] = collection.mMdPubEncodedJson ?? '{}';
|
||||
|
||||
row[columnSharedMMdVersion] = collection.sharedMmdVersion;
|
||||
row[columnSharedMMdJson] = collection.sharedMmdJson ?? '{}';
|
||||
return row;
|
||||
}
|
||||
|
||||
Collection _convertToCollection(Map<String, dynamic> row) {
|
||||
final Collection result = Collection(
|
||||
row[columnID],
|
||||
User.fromJson(row[columnOwner]),
|
||||
row[columnEncryptedKey],
|
||||
row[columnKeyDecryptionNonce],
|
||||
row[columnName],
|
||||
row[columnEncryptedName],
|
||||
row[columnNameDecryptionNonce],
|
||||
typeFromString(row[columnType]),
|
||||
CollectionAttributes(
|
||||
encryptedPath: row[columnEncryptedPath],
|
||||
pathDecryptionNonce: row[columnPathDecryptionNonce],
|
||||
version: row[columnVersion],
|
||||
),
|
||||
List<User>.from(
|
||||
(json.decode(row[columnSharees]) as List).map((x) => User.fromMap(x)),
|
||||
),
|
||||
row[columnPublicURLs] == null
|
||||
? []
|
||||
: List<PublicURL>.from(
|
||||
(json.decode(row[columnPublicURLs]) as List)
|
||||
.map((x) => PublicURL.fromMap(x)),
|
||||
),
|
||||
int.parse(row[columnUpdationTime]),
|
||||
// default to False is columnIsDeleted is not set
|
||||
isDeleted: (row[columnIsDeleted] ?? _sqlBoolFalse) == _sqlBoolTrue,
|
||||
);
|
||||
result.mMdVersion = row[columnMMdVersion] ?? 0;
|
||||
result.mMdEncodedJson = row[columnMMdEncodedJson] ?? '{}';
|
||||
result.mMbPubVersion = row[columnPubMMdVersion] ?? 0;
|
||||
result.mMdPubEncodedJson = row[columnPubMMdEncodedJson] ?? '{}';
|
||||
|
||||
result.sharedMmdVersion = row[columnSharedMMdVersion] ?? 0;
|
||||
result.sharedMmdJson = row[columnSharedMMdJson] ?? '{}';
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import "package:flutter/foundation.dart";
|
||||
import "package:sqlite_async/sqlite_async.dart";
|
||||
|
||||
mixin SqlDbBase {
|
||||
static const _params = {};
|
||||
static final _params = {};
|
||||
|
||||
static String getParams(int count) {
|
||||
String getParams(int count) {
|
||||
if (!_params.containsKey(count)) {
|
||||
final params = List.generate(count, (_) => "?").join(", ");
|
||||
_params[count] = params;
|
||||
@@ -14,9 +14,13 @@ mixin SqlDbBase {
|
||||
|
||||
Future<void> migrate(
|
||||
SqliteDatabase database,
|
||||
List<String> migrationScripts,
|
||||
) async {
|
||||
List<String> migrationScripts, {
|
||||
bool onForeignKey = false,
|
||||
}) async {
|
||||
final result = await database.execute('PRAGMA user_version');
|
||||
if (onForeignKey) {
|
||||
await database.execute("PRAGMA foreign_keys = ON");
|
||||
}
|
||||
final currentVersion = result[0]['user_version'] as int;
|
||||
final toVersion = migrationScripts.length;
|
||||
|
||||
|
||||
@@ -1,492 +0,0 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:photos/db/files_db.dart';
|
||||
import 'package:photos/models/backup_status.dart';
|
||||
import 'package:photos/models/device_collection.dart';
|
||||
import 'package:photos/models/file/file.dart';
|
||||
import 'package:photos/models/file_load_result.dart';
|
||||
import 'package:photos/models/upload_strategy.dart';
|
||||
import "package:photos/services/sync/import/model.dart";
|
||||
import 'package:sqflite/sqlite_api.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
extension DeviceFiles on FilesDB {
|
||||
static final Logger _logger = Logger("DeviceFilesDB");
|
||||
static const _sqlBoolTrue = 1;
|
||||
static const _sqlBoolFalse = 0;
|
||||
|
||||
Future<void> insertPathIDToLocalIDMapping(
|
||||
Map<String, Set<String>> mappingToAdd, {
|
||||
ConflictAlgorithm conflictAlgorithm = ConflictAlgorithm.ignore,
|
||||
}) async {
|
||||
debugPrint("Inserting missing PathIDToLocalIDMapping");
|
||||
final parameterSets = <List<Object?>>[];
|
||||
int batchCounter = 0;
|
||||
for (MapEntry e in mappingToAdd.entries) {
|
||||
final String pathID = e.key;
|
||||
for (String localID in e.value) {
|
||||
parameterSets.add([localID, pathID]);
|
||||
batchCounter++;
|
||||
|
||||
if (batchCounter == 400) {
|
||||
await _insertBatch(parameterSets, conflictAlgorithm);
|
||||
parameterSets.clear();
|
||||
batchCounter = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
await _insertBatch(parameterSets, conflictAlgorithm);
|
||||
parameterSets.clear();
|
||||
batchCounter = 0;
|
||||
}
|
||||
|
||||
Future<void> deletePathIDToLocalIDMapping(
|
||||
Map<String, Set<String>> mappingsToRemove,
|
||||
) async {
|
||||
debugPrint("removing PathIDToLocalIDMapping");
|
||||
final parameterSets = <List<Object?>>[];
|
||||
int batchCounter = 0;
|
||||
for (MapEntry e in mappingsToRemove.entries) {
|
||||
final String pathID = e.key;
|
||||
|
||||
for (String localID in e.value) {
|
||||
parameterSets.add([localID, pathID]);
|
||||
batchCounter++;
|
||||
|
||||
if (batchCounter == 400) {
|
||||
await _deleteBatch(parameterSets);
|
||||
parameterSets.clear();
|
||||
batchCounter = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
await _deleteBatch(parameterSets);
|
||||
parameterSets.clear();
|
||||
batchCounter = 0;
|
||||
}
|
||||
|
||||
Future<Map<String, int>> getDevicePathIDToImportedFileCount() async {
|
||||
try {
|
||||
final db = await sqliteAsyncDB;
|
||||
final rows = await db.getAll(
|
||||
'''
|
||||
SELECT count(*) as count, path_id
|
||||
FROM device_files
|
||||
GROUP BY path_id
|
||||
''',
|
||||
);
|
||||
final result = <String, int>{};
|
||||
for (final row in rows) {
|
||||
result[row['path_id'] as String] = row["count"] as int;
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
_logger.severe("failed to getDevicePathIDToImportedFileCount", e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, Set<String>>> getDevicePathIDToLocalIDMap() async {
|
||||
try {
|
||||
final db = await sqliteAsyncDB;
|
||||
final rows = await db.getAll(
|
||||
''' SELECT id, path_id FROM device_files; ''',
|
||||
);
|
||||
final result = <String, Set<String>>{};
|
||||
for (final row in rows) {
|
||||
final String pathID = row['path_id'] as String;
|
||||
if (!result.containsKey(pathID)) {
|
||||
result[pathID] = <String>{};
|
||||
}
|
||||
result[pathID]!.add(row['id'] as String);
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
_logger.severe("failed to getDevicePathIDToLocalIDMap", e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Set<String>> getDevicePathIDs() async {
|
||||
final db = await sqliteAsyncDB;
|
||||
final rows = await db.getAll(
|
||||
'''
|
||||
SELECT id FROM device_collections
|
||||
''',
|
||||
);
|
||||
final Set<String> result = <String>{};
|
||||
for (final row in rows) {
|
||||
result.add(row['id'] as String);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> insertLocalAssets(
|
||||
List<LocalPathAsset> localPathAssets, {
|
||||
bool shouldAutoBackup = false,
|
||||
}) async {
|
||||
final db = await sqliteAsyncDB;
|
||||
final Map<String, Set<String>> pathIDToLocalIDsMap = {};
|
||||
try {
|
||||
final Set<String> existingPathIds = await getDevicePathIDs();
|
||||
final parameterSetsForUpdate = <List<Object?>>[];
|
||||
final parameterSetsForInsert = <List<Object?>>[];
|
||||
for (LocalPathAsset localPathAsset in localPathAssets) {
|
||||
if (localPathAsset.localIDs.isNotEmpty) {
|
||||
pathIDToLocalIDsMap[localPathAsset.pathID] = localPathAsset.localIDs;
|
||||
}
|
||||
if (existingPathIds.contains(localPathAsset.pathID)) {
|
||||
parameterSetsForUpdate
|
||||
.add([localPathAsset.pathName, localPathAsset.pathID]);
|
||||
} else if (localPathAsset.localIDs.isNotEmpty) {
|
||||
parameterSetsForInsert.add([
|
||||
localPathAsset.pathID,
|
||||
localPathAsset.pathName,
|
||||
shouldAutoBackup ? _sqlBoolTrue : _sqlBoolFalse,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
await db.executeBatch(
|
||||
'''
|
||||
INSERT OR IGNORE INTO device_collections (id, name, should_backup) VALUES (?, ?, ?);
|
||||
''',
|
||||
parameterSetsForInsert,
|
||||
);
|
||||
|
||||
await db.executeBatch(
|
||||
'''
|
||||
UPDATE device_collections SET name = ? WHERE id = ?;
|
||||
''',
|
||||
parameterSetsForUpdate,
|
||||
);
|
||||
|
||||
// add the mappings for localIDs
|
||||
if (pathIDToLocalIDsMap.isNotEmpty) {
|
||||
await insertPathIDToLocalIDMapping(pathIDToLocalIDsMap);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.severe("failed to save path names", e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> updateDeviceCoverWithCount(
|
||||
List<Tuple2<AssetPathEntity, String>> devicePathInfo, {
|
||||
bool shouldBackup = false,
|
||||
}) async {
|
||||
bool hasUpdated = false;
|
||||
try {
|
||||
final db = await sqliteAsyncDB;
|
||||
final Set<String> existingPathIds = await getDevicePathIDs();
|
||||
for (Tuple2<AssetPathEntity, String> tup in devicePathInfo) {
|
||||
final AssetPathEntity pathEntity = tup.item1;
|
||||
final assetCount = await pathEntity.assetCountAsync;
|
||||
final String localID = tup.item2;
|
||||
final bool shouldUpdate = existingPathIds.contains(pathEntity.id);
|
||||
if (shouldUpdate) {
|
||||
final rowUpdated = await db.writeTransaction((tx) async {
|
||||
await tx.execute(
|
||||
"UPDATE device_collections SET name = ?, cover_id = ?, count"
|
||||
" = ? where id = ? AND (name != ? OR cover_id != ? OR count != ?)",
|
||||
[
|
||||
pathEntity.name,
|
||||
localID,
|
||||
assetCount,
|
||||
pathEntity.id,
|
||||
pathEntity.name,
|
||||
localID,
|
||||
assetCount,
|
||||
],
|
||||
);
|
||||
final result = await tx.get("SELECT changes();");
|
||||
return result["changes()"] as int;
|
||||
});
|
||||
|
||||
if (rowUpdated > 0) {
|
||||
_logger.info("Updated $rowUpdated rows for ${pathEntity.name}");
|
||||
hasUpdated = true;
|
||||
}
|
||||
} else {
|
||||
hasUpdated = true;
|
||||
await db.execute(
|
||||
'''
|
||||
INSERT INTO device_collections (id, name, count, cover_id, should_backup)
|
||||
VALUES (?, ?, ?, ?, ?);
|
||||
''',
|
||||
[
|
||||
pathEntity.id,
|
||||
pathEntity.name,
|
||||
assetCount,
|
||||
localID,
|
||||
shouldBackup ? _sqlBoolTrue : _sqlBoolFalse,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
// delete existing pathIDs which are missing on device
|
||||
existingPathIds.removeAll(devicePathInfo.map((e) => e.item1.id).toSet());
|
||||
if (existingPathIds.isNotEmpty) {
|
||||
hasUpdated = true;
|
||||
_logger.info(
|
||||
'Deleting non-backed up pathIds from local '
|
||||
'$existingPathIds',
|
||||
);
|
||||
for (String pathID in existingPathIds) {
|
||||
// do not delete device collection entries for paths which are
|
||||
// marked for backup. This is to handle "Free up space"
|
||||
// feature, where we delete files which are backed up. Deleting such
|
||||
// entries here result in us losing out on the information that
|
||||
// those folders were marked for automatic backup.
|
||||
await db.execute(
|
||||
'''
|
||||
DELETE FROM device_collections WHERE id = ? AND should_backup = $_sqlBoolFalse;
|
||||
''',
|
||||
[pathID],
|
||||
);
|
||||
await db.execute(
|
||||
'''
|
||||
DELETE FROM device_files WHERE path_id = ?;
|
||||
''',
|
||||
[pathID],
|
||||
);
|
||||
}
|
||||
}
|
||||
return hasUpdated;
|
||||
} catch (e) {
|
||||
_logger.severe("failed to save path names", e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// getDeviceSyncCollectionIDs returns the collectionIDs for the
|
||||
// deviceCollections which are marked for auto-backup
|
||||
Future<Set<int>> getDeviceSyncCollectionIDs() async {
|
||||
final db = await sqliteAsyncDB;
|
||||
final rows = await db.getAll(
|
||||
'''
|
||||
SELECT collection_id FROM device_collections where should_backup =
|
||||
$_sqlBoolTrue
|
||||
and collection_id != -1;
|
||||
''',
|
||||
);
|
||||
final Set<int> result = <int>{};
|
||||
for (final row in rows) {
|
||||
result.add(row['collection_id'] as int);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> updateDevicePathSyncStatus(
|
||||
Map<String, bool> syncStatus,
|
||||
) async {
|
||||
final db = await sqliteAsyncDB;
|
||||
int batchCounter = 0;
|
||||
final parameterSets = <List<Object?>>[];
|
||||
for (MapEntry e in syncStatus.entries) {
|
||||
final String pathID = e.key;
|
||||
parameterSets.add([e.value ? _sqlBoolTrue : _sqlBoolFalse, pathID]);
|
||||
batchCounter++;
|
||||
|
||||
if (batchCounter == 400) {
|
||||
await db.executeBatch(
|
||||
'''
|
||||
UPDATE device_collections SET should_backup = ? WHERE id = ?;
|
||||
''',
|
||||
parameterSets,
|
||||
);
|
||||
parameterSets.clear();
|
||||
batchCounter = 0;
|
||||
}
|
||||
}
|
||||
|
||||
await db.executeBatch(
|
||||
'''
|
||||
UPDATE device_collections SET should_backup = ? WHERE id = ?;
|
||||
''',
|
||||
parameterSets,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateDeviceCollection(
|
||||
String pathID,
|
||||
int collectionID,
|
||||
) async {
|
||||
final db = await sqliteAsyncDB;
|
||||
await db.execute(
|
||||
'''
|
||||
UPDATE device_collections SET collection_id = ? WHERE id = ?;
|
||||
''',
|
||||
[collectionID, pathID],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Future<FileLoadResult> getFilesInDeviceCollection(
|
||||
DeviceCollection deviceCollection,
|
||||
int? ownerID,
|
||||
int startTime,
|
||||
int endTime, {
|
||||
int? limit,
|
||||
bool? asc,
|
||||
}) async {
|
||||
final db = await sqliteAsyncDB;
|
||||
final order = (asc ?? false ? 'ASC' : 'DESC');
|
||||
final String rawQuery = '''
|
||||
SELECT *
|
||||
FROM ${FilesDB.filesTable}
|
||||
WHERE ${FilesDB.columnLocalID} IS NOT NULL AND
|
||||
${FilesDB.columnCreationTime} >= $startTime AND
|
||||
${FilesDB.columnCreationTime} <= $endTime AND
|
||||
(${FilesDB.columnOwnerID} IS NULL OR ${FilesDB.columnOwnerID} =
|
||||
$ownerID ) AND
|
||||
${FilesDB.columnLocalID} IN
|
||||
(SELECT id FROM device_files where path_id = '${deviceCollection.id}' )
|
||||
ORDER BY ${FilesDB.columnCreationTime} $order , ${FilesDB.columnModificationTime} $order
|
||||
''' +
|
||||
(limit != null ? ' limit $limit;' : ';');
|
||||
final results = await db.getAll(rawQuery);
|
||||
final files = convertToFiles(results);
|
||||
final dedupe = deduplicateByLocalID(files);
|
||||
return FileLoadResult(dedupe, files.length == limit);
|
||||
}
|
||||
|
||||
Future<BackedUpFileIDs> getBackedUpForDeviceCollection(
|
||||
String pathID,
|
||||
int ownerID,
|
||||
) async {
|
||||
final db = await sqliteAsyncDB;
|
||||
const String rawQuery = '''
|
||||
SELECT ${FilesDB.columnLocalID}, ${FilesDB.columnUploadedFileID},
|
||||
${FilesDB.columnFileSize}
|
||||
FROM ${FilesDB.filesTable}
|
||||
WHERE ${FilesDB.columnLocalID} IS NOT NULL AND
|
||||
(${FilesDB.columnOwnerID} IS NULL OR ${FilesDB.columnOwnerID} = ?)
|
||||
AND (${FilesDB.columnUploadedFileID} IS NOT NULL AND ${FilesDB.columnUploadedFileID} IS NOT -1)
|
||||
AND
|
||||
${FilesDB.columnLocalID} IN
|
||||
(SELECT id FROM device_files where path_id = ?)
|
||||
''';
|
||||
final results = await db.getAll(rawQuery, [ownerID, pathID]);
|
||||
final localIDs = <String>{};
|
||||
final uploadedIDs = <int>{};
|
||||
int localSize = 0;
|
||||
for (final result in results) {
|
||||
final String localID = result[FilesDB.columnLocalID] as String;
|
||||
final int? fileSize = result[FilesDB.columnFileSize] as int?;
|
||||
if (!localIDs.contains(localID) && fileSize != null) {
|
||||
localSize += fileSize;
|
||||
}
|
||||
localIDs.add(localID);
|
||||
uploadedIDs.add(result[FilesDB.columnUploadedFileID] as int);
|
||||
}
|
||||
return BackedUpFileIDs(localIDs.toList(), uploadedIDs.toList(), localSize);
|
||||
}
|
||||
|
||||
Future<List<DeviceCollection>> getDeviceCollections({
|
||||
bool includeCoverThumbnail = false,
|
||||
}) async {
|
||||
debugPrint(
|
||||
"Fetching DeviceCollections From DB with thumbnail = "
|
||||
"$includeCoverThumbnail",
|
||||
);
|
||||
try {
|
||||
final db = await sqliteAsyncDB;
|
||||
final coverFiles = <EnteFile>[];
|
||||
if (includeCoverThumbnail) {
|
||||
final fileRows = await db.getAll(
|
||||
'''SELECT * FROM FILES where local_id in (select cover_id from device_collections) group by local_id;
|
||||
''',
|
||||
);
|
||||
final files = convertToFiles(fileRows);
|
||||
coverFiles.addAll(files);
|
||||
}
|
||||
final deviceCollectionRows = await db.getAll(
|
||||
'''SELECT * from device_collections''',
|
||||
);
|
||||
final List<DeviceCollection> deviceCollections = [];
|
||||
for (var row in deviceCollectionRows) {
|
||||
final DeviceCollection deviceCollection = DeviceCollection(
|
||||
row["id"] as String,
|
||||
(row['name'] ?? '') as String,
|
||||
count: row['count'] as int,
|
||||
collectionID: (row["collection_id"] ?? -1) as int,
|
||||
coverId: row["cover_id"] as String?,
|
||||
shouldBackup: (row["should_backup"] ?? _sqlBoolFalse) == _sqlBoolTrue,
|
||||
uploadStrategy: getUploadType((row["upload_strategy"] ?? 0) as int),
|
||||
);
|
||||
if (includeCoverThumbnail) {
|
||||
deviceCollection.thumbnail = coverFiles.firstWhereOrNull(
|
||||
(element) => element.localID == deviceCollection.coverId,
|
||||
);
|
||||
if (deviceCollection.thumbnail == null) {
|
||||
final EnteFile? result =
|
||||
await getDeviceCollectionThumbnail(deviceCollection.id);
|
||||
if (result == null) {
|
||||
_logger.info(
|
||||
'Failed to find coverThumbnail for deviceFolder',
|
||||
);
|
||||
continue;
|
||||
} else {
|
||||
deviceCollection.thumbnail = result;
|
||||
}
|
||||
}
|
||||
}
|
||||
deviceCollections.add(deviceCollection);
|
||||
}
|
||||
if (includeCoverThumbnail) {
|
||||
deviceCollections.sort(
|
||||
(a, b) =>
|
||||
b.thumbnail!.creationTime!.compareTo(a.thumbnail!.creationTime!),
|
||||
);
|
||||
}
|
||||
return deviceCollections;
|
||||
} catch (e) {
|
||||
_logger.severe('Failed to getDeviceCollections', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<EnteFile?> getDeviceCollectionThumbnail(String pathID) async {
|
||||
debugPrint("Call fallback method to get potential thumbnail");
|
||||
final db = await sqliteAsyncDB;
|
||||
final fileRows = await db.getAll(
|
||||
'''SELECT * FROM FILES f JOIN device_files df on f.local_id = df.id
|
||||
and df.path_id= ? order by f.creation_time DESC limit 1;
|
||||
''',
|
||||
[pathID],
|
||||
);
|
||||
final files = convertToFiles(fileRows);
|
||||
if (files.isNotEmpty) {
|
||||
return files.first;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _insertBatch(
|
||||
List<List<Object?>> parameterSets,
|
||||
ConflictAlgorithm conflictAlgorithm,
|
||||
) async {
|
||||
final db = await sqliteAsyncDB;
|
||||
await db.executeBatch(
|
||||
'''
|
||||
INSERT OR ${conflictAlgorithm.name.toUpperCase()}
|
||||
INTO device_files (id, path_id) VALUES (?, ?);
|
||||
''',
|
||||
parameterSets,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteBatch(List<List<Object?>> parameterSets) async {
|
||||
final db = await sqliteAsyncDB;
|
||||
await db.executeBatch(
|
||||
'''
|
||||
DELETE FROM device_files WHERE id = ? AND path_id = ?;
|
||||
''',
|
||||
parameterSets,
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
333
mobile/apps/photos/lib/db/local/db.dart
Normal file
333
mobile/apps/photos/lib/db/local/db.dart
Normal file
@@ -0,0 +1,333 @@
|
||||
import "dart:io";
|
||||
|
||||
import "package:collection/collection.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:path/path.dart";
|
||||
import "package:path_provider/path_provider.dart";
|
||||
import "package:photo_manager/photo_manager.dart";
|
||||
import "package:photos/db/common/base.dart";
|
||||
import "package:photos/db/local/mappers.dart";
|
||||
import "package:photos/db/local/schema.dart";
|
||||
import "package:photos/log/devlog.dart";
|
||||
import 'package:photos/models/file/file.dart';
|
||||
import "package:photos/models/file/mapping/local_mapping.dart";
|
||||
import "package:photos/models/local/local_metadata.dart";
|
||||
import "package:sqlite_async/sqlite_async.dart";
|
||||
|
||||
class LocalDB with SqlDbBase {
|
||||
static const _databaseName = "local_6.db";
|
||||
static const batchInsertMaxCount = 1000;
|
||||
static const _smallTableBatchInsertMaxCount = 5000;
|
||||
late final SqliteDatabase _sqliteDB;
|
||||
SqliteDatabase get sqliteDB => _sqliteDB;
|
||||
|
||||
Future<void> init() async {
|
||||
devLog("LocalDB init");
|
||||
final Directory documentsDirectory =
|
||||
await getApplicationDocumentsDirectory();
|
||||
final String path = join(documentsDirectory.path, _databaseName);
|
||||
|
||||
final db = SqliteDatabase(path: path);
|
||||
await migrate(db, LocalDBMigration.migrationScripts, onForeignKey: true);
|
||||
_sqliteDB = db;
|
||||
debugPrint("LocalDB init complete $path");
|
||||
}
|
||||
|
||||
Future<void> insertAssets(List<AssetEntity> entries) async {
|
||||
if (entries.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.forEach(entries.slices(batchInsertMaxCount), (slice) async {
|
||||
final List<List<Object?>> values =
|
||||
slice.map((e) => LocalDBMappers.assetsRow(e)).toList();
|
||||
await _sqliteDB.executeBatch(
|
||||
'INSERT INTO assets ($assetColumns) values(${getParams(16)}) ON CONFLICT(id) DO UPDATE SET $updateAssetColumns',
|
||||
values,
|
||||
);
|
||||
});
|
||||
debugPrint(
|
||||
'$runtimeType insertAssets complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} assets',
|
||||
);
|
||||
}
|
||||
|
||||
// Store time and location metadata inside edited_assets
|
||||
Future<void> trackEdit(
|
||||
String id,
|
||||
int createdAt,
|
||||
int modifiedAt,
|
||||
double? lat,
|
||||
double? lng,
|
||||
) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await _sqliteDB.execute(
|
||||
'INSERT INTO edited_assets (id, created_at, modified_at, latitude, longitude) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET created_at = ?, modified_at = ?, latitude = ?, longitude = ?',
|
||||
[id, createdAt, modifiedAt, lat, lng, createdAt, modifiedAt, lat, lng],
|
||||
);
|
||||
debugPrint(
|
||||
'$runtimeType editCopy complete in ${stopwatch.elapsed.inMilliseconds}ms for $id',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateMetadata(
|
||||
String id, {
|
||||
DroidMetadata? droid,
|
||||
IOSMetadata? ios,
|
||||
}) async {
|
||||
if (droid != null) {
|
||||
await _sqliteDB.execute(
|
||||
'UPDATE assets SET size = ?, hash = ?, latitude = ?, longitude = ?, created_at = ?, modified_at = ?, scan_state = 1 WHERE id = ?',
|
||||
[
|
||||
droid.size,
|
||||
droid.hash,
|
||||
droid.location?.latitude,
|
||||
droid.location?.longitude,
|
||||
droid.creationTime,
|
||||
droid.modificationTime,
|
||||
id,
|
||||
],
|
||||
);
|
||||
} else if (ios != null) {
|
||||
// await _sqliteDB.execute(
|
||||
// 'UPDATE assets SET size = ?, hash = ?, latitude = ?, longitude = ?, created_at = ?, modified_at = ? WHERE id = ?',
|
||||
// [
|
||||
// ios.size,
|
||||
// ios.hash,
|
||||
// ios.location.latitude,
|
||||
// ios.location.longitude,
|
||||
// ios.creationTime.millisecondsSinceEpoch,
|
||||
// ios.modificationTime.millisecondsSinceEpoch,
|
||||
// ios.id,
|
||||
// ],
|
||||
// );
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, LocalAssetInfo>> getLocalAssetsInfo(
|
||||
List<String> ids,
|
||||
) async {
|
||||
if (ids.isEmpty) return {};
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final result = await _sqliteDB.getAll(
|
||||
'SELECT id, hash, title, relative_path, scan_state FROM assets WHERE id IN (${List.filled(ids.length, "?").join(",")})',
|
||||
ids,
|
||||
);
|
||||
debugPrint(
|
||||
"getLocalAssetsInfo complete in ${stopwatch.elapsed.inMilliseconds}ms for ${ids.length} ids",
|
||||
);
|
||||
return Map.fromEntries(
|
||||
result.map(
|
||||
(row) => MapEntry(row['id'] as String, LocalAssetInfo.fromRow(row)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<EnteFile>> getAssets({LocalAssertsParam? params}) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final result = await _sqliteDB.getAll(
|
||||
"SELECT * FROM assets ${params != null ? params.whereClause(addWhere: true) : ""}",
|
||||
);
|
||||
debugPrint(
|
||||
"getAssets complete in ${stopwatch.elapsed.inMilliseconds}ms, params: ${params?.whereClause()}",
|
||||
);
|
||||
// if time is greater than 1000ms, print explain analyze out
|
||||
if (kDebugMode && stopwatch.elapsed.inMilliseconds > 1000) {
|
||||
final explain = await _sqliteDB.execute(
|
||||
"EXPLAIN QUERY PLAN SELECT * FROM assets ${params != null ? params.whereClause(addWhere: true) : ""}",
|
||||
);
|
||||
debugPrint("getAssets: Explain Query Plan: $explain");
|
||||
}
|
||||
stopwatch.reset();
|
||||
stopwatch.start();
|
||||
final r =
|
||||
result.map((row) => LocalDBMappers.assetRowToEnteFile(row)).toList();
|
||||
debugPrint(
|
||||
"getAssets mapping completed in ${stopwatch.elapsed.inMilliseconds}ms",
|
||||
);
|
||||
return r;
|
||||
}
|
||||
|
||||
Future<List<EnteFile>> getPathAssets(
|
||||
String pathID, {
|
||||
LocalAssertsParam? params,
|
||||
}) async {
|
||||
final String query =
|
||||
"SELECT * FROM assets WHERE id IN (SELECT asset_id FROM device_path_assets WHERE path_id = ?) ${params != null ? 'AND ${params.whereClause()}' : "order by created_at desc"}";
|
||||
debugPrint(query);
|
||||
final result = await _sqliteDB.getAll(
|
||||
query,
|
||||
[pathID],
|
||||
);
|
||||
return result.map((row) => LocalDBMappers.assetRowToEnteFile(row)).toList();
|
||||
}
|
||||
|
||||
Future<void> insertDBPaths(List<AssetPathEntity> entries) async {
|
||||
if (entries.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.forEach(entries.slices(_smallTableBatchInsertMaxCount),
|
||||
(slice) async {
|
||||
final List<List<Object?>> values =
|
||||
slice.map((e) => LocalDBMappers.devicePathRow(e)).toList();
|
||||
await _sqliteDB.executeBatch(
|
||||
'INSERT INTO device_path ($devicePathColumns) values(${getParams(5)}) ON CONFLICT(path_id) DO UPDATE SET $updateDevicePathColumns',
|
||||
values,
|
||||
);
|
||||
});
|
||||
debugPrint(
|
||||
'$runtimeType insertDBPaths complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} paths',
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<AssetPathEntity>> getAssetPaths() async {
|
||||
final result = await _sqliteDB.getAll(
|
||||
"SELECT * FROM device_path",
|
||||
);
|
||||
return result.map((row) => LocalDBMappers.assetPath(row)).toList();
|
||||
}
|
||||
|
||||
Future<void> insertPathToAssetIDs(
|
||||
Map<String, Set<String>> pathToAssetIDs, {
|
||||
bool clearOldMappingsIdsInInput = false,
|
||||
}) async {
|
||||
if (pathToAssetIDs.isEmpty) return;
|
||||
final List<List<String>> allValues = [];
|
||||
pathToAssetIDs.forEach((pathID, assetIDs) {
|
||||
allValues.addAll(assetIDs.map((assetID) => [pathID, assetID]));
|
||||
});
|
||||
if (allValues.isEmpty && !clearOldMappingsIdsInInput) {
|
||||
return;
|
||||
}
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
||||
await _sqliteDB.writeTransaction((tx) async {
|
||||
if (clearOldMappingsIdsInInput) {
|
||||
await tx.execute(
|
||||
"DELETE FROM device_path_assets WHERE path_id IN (${List.generate(pathToAssetIDs.keys.length, (index) => '?').join(',')})",
|
||||
pathToAssetIDs.keys.toList(),
|
||||
);
|
||||
}
|
||||
const int batchSize = 15000;
|
||||
for (int i = 0; i < allValues.length; i += batchSize) {
|
||||
await tx.executeBatch(
|
||||
'INSERT OR REPLACE INTO device_path_assets (path_id, asset_id) VALUES (?, ?)',
|
||||
allValues.sublist(
|
||||
i,
|
||||
i + batchSize > allValues.length ? allValues.length : i + batchSize,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
debugPrint(
|
||||
'$runtimeType insertPathToAssetIDs ${allValues.length} complete in '
|
||||
'${stopwatch.elapsed.inMilliseconds}ms for '
|
||||
'${pathToAssetIDs.length} paths (replaced $clearOldMappingsIdsInInput}',
|
||||
);
|
||||
}
|
||||
|
||||
Future<Set<String>> getAssetsIDs({bool pendingScan = false}) async {
|
||||
final result = await _sqliteDB.getAll(
|
||||
"SELECT id FROM assets ${pendingScan ? 'WHERE scan_state != $finalState ORDER BY created_at DESC' : ''}",
|
||||
);
|
||||
final ids = <String>{};
|
||||
for (var row in result) {
|
||||
ids.add(row["id"] as String);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
Future<Set<String>> getAssetsIDsForPath(
|
||||
String pathID,
|
||||
) async {
|
||||
final result = await _sqliteDB.getAll(
|
||||
"SELECT asset_id FROM device_path_assets WHERE path_id = ? ",
|
||||
[pathID],
|
||||
);
|
||||
final ids = <String>{};
|
||||
for (var row in result) {
|
||||
ids.add(row["asset_id"] as String);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
Future<Map<String, int>> getIDToCreationTime() async {
|
||||
final result = await _sqliteDB.getAll(
|
||||
"SELECT id, created_at FROM assets",
|
||||
);
|
||||
final idToCreationTime = <String, int>{};
|
||||
for (var row in result) {
|
||||
idToCreationTime[row["id"] as String] = row["created_at"] as int;
|
||||
}
|
||||
return idToCreationTime;
|
||||
}
|
||||
|
||||
Future<Map<String, Set<String>>> pathToAssetIDs() async {
|
||||
final result = await _sqliteDB
|
||||
.getAll("SELECT path_id, asset_id FROM device_path_assets");
|
||||
final pathToAssetIDs = <String, Set<String>>{};
|
||||
for (var row in result) {
|
||||
final pathID = row["path_id"] as String;
|
||||
final assetID = row["asset_id"] as String;
|
||||
if (pathToAssetIDs.containsKey(pathID)) {
|
||||
pathToAssetIDs[pathID]!.add(assetID);
|
||||
} else {
|
||||
pathToAssetIDs[pathID] = {assetID};
|
||||
}
|
||||
}
|
||||
return pathToAssetIDs;
|
||||
}
|
||||
|
||||
Future<void> deleteAssets(Set<String> ids) async {
|
||||
if (ids.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await _sqliteDB.execute(
|
||||
'DELETE FROM assets WHERE id IN (${List.filled(ids.length, "?").join(",")})',
|
||||
ids.toList(),
|
||||
);
|
||||
debugPrint(
|
||||
'$runtimeType deleteEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${ids.length} assets entries',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deletePaths(Set<String> pathIds) async {
|
||||
if (pathIds.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await _sqliteDB.execute(
|
||||
'DELETE FROM device_path WHERE path_id IN (${List.filled(pathIds.length, "?").join(",")})',
|
||||
pathIds.toList(),
|
||||
);
|
||||
debugPrint(
|
||||
'$runtimeType deleteEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${pathIds.length} path entries',
|
||||
);
|
||||
}
|
||||
|
||||
// returns true if either asset queue or shared_assets has any entry for given ownerID
|
||||
Future<bool> hasAssetQueueOrSharedAsset(int ownerID) async {
|
||||
final result = await _sqliteDB.getAll(
|
||||
'''
|
||||
SELECT 1 FROM asset_upload_queue WHERE owner_id = ?
|
||||
UNION ALL
|
||||
SELECT 1 FROM shared_assets WHERE owner_id = ?
|
||||
LIMIT 1
|
||||
''',
|
||||
[ownerID, ownerID],
|
||||
);
|
||||
return result.isNotEmpty;
|
||||
}
|
||||
|
||||
Future<(int, int)> getUniqueQueueAndSharedAssetsCount(
|
||||
int ownerID,
|
||||
) async {
|
||||
final queuedAssets = await _sqliteDB.getAll(
|
||||
'SELECT COUNT(distinct asset_id) as count FROM asset_upload_queue WHERE owner_id = ?',
|
||||
[ownerID],
|
||||
);
|
||||
final sharedAssets = await _sqliteDB.getAll(
|
||||
'SELECT COUNT(*) as count FROM shared_assets WHERE owner_id = ?',
|
||||
[ownerID],
|
||||
);
|
||||
final queuedCount =
|
||||
queuedAssets.isNotEmpty ? (queuedAssets.first['count'] as int) : 0;
|
||||
final sharedCount =
|
||||
sharedAssets.isNotEmpty ? (sharedAssets.first['count'] as int) : 0;
|
||||
return (queuedCount, sharedCount);
|
||||
}
|
||||
}
|
||||
100
mobile/apps/photos/lib/db/local/mappers.dart
Normal file
100
mobile/apps/photos/lib/db/local/mappers.dart
Normal file
@@ -0,0 +1,100 @@
|
||||
import "dart:io";
|
||||
|
||||
import "package:photo_manager/photo_manager.dart";
|
||||
import "package:photos/models/file/file.dart";
|
||||
|
||||
class LocalDBMappers {
|
||||
const LocalDBMappers._();
|
||||
|
||||
static List<Object?> assetsRow(AssetEntity entity) {
|
||||
return [
|
||||
entity.id,
|
||||
entity.type.index,
|
||||
entity.subtype,
|
||||
entity.width,
|
||||
entity.height,
|
||||
entity.duration,
|
||||
entity.orientation,
|
||||
entity.isFavorite ? 1 : 0,
|
||||
entity.title,
|
||||
entity.relativePath,
|
||||
entity.createDateTime.microsecondsSinceEpoch,
|
||||
entity.modifiedDateTime.microsecondsSinceEpoch,
|
||||
entity.mimeType,
|
||||
entity.latitude,
|
||||
entity.longitude,
|
||||
0, // scan_state
|
||||
];
|
||||
}
|
||||
|
||||
static AssetEntity asset(Map<String, dynamic> row) {
|
||||
return AssetEntity(
|
||||
id: row['id'] as String,
|
||||
typeInt: row['type'] as int,
|
||||
subtype: row['sub_type'] as int,
|
||||
width: row['width'] as int,
|
||||
height: row['height'] as int,
|
||||
duration: row['duration_in_sec'] as int,
|
||||
orientation: row['orientation'] as int,
|
||||
isFavorite: (row['is_fav'] as int) == 1,
|
||||
title: row['title'] as String?,
|
||||
relativePath: row['relative_path'] as String?,
|
||||
createDateSecond: (row['created_at'] as int) ~/ 1000000,
|
||||
modifiedDateSecond: (row['modified_at'] as int) ~/ 1000000,
|
||||
mimeType: row['mime_type'] as String?,
|
||||
latitude: row['latitude'] as double?,
|
||||
longitude: row['longitude'] as double?,
|
||||
);
|
||||
}
|
||||
|
||||
static EnteFile assetRowToEnteFile(Map<String, dynamic> row) {
|
||||
final asset = AssetEntity(
|
||||
id: row['id'] as String,
|
||||
typeInt: row['type'] as int,
|
||||
subtype: row['sub_type'] as int,
|
||||
width: row['width'] as int,
|
||||
height: row['height'] as int,
|
||||
duration: row['duration_in_sec'] as int,
|
||||
orientation: row['orientation'] as int,
|
||||
isFavorite: (row['is_fav'] as int) == 1,
|
||||
title: row['title'] as String?,
|
||||
relativePath: row['relative_path'] as String?,
|
||||
createDateSecond: (row['created_at'] as int) ~/ 1000000,
|
||||
modifiedDateSecond: (row['modified_at'] as int) ~/ 1000000,
|
||||
mimeType: row['mime_type'] as String?,
|
||||
latitude: row['latitude'] as double?,
|
||||
longitude: row['longitude'] as double?,
|
||||
);
|
||||
return EnteFile.fromAssetSync(asset);
|
||||
}
|
||||
|
||||
static List<Object?> devicePathRow(AssetPathEntity entity) {
|
||||
return [
|
||||
entity.id,
|
||||
entity.name,
|
||||
entity.albumType,
|
||||
entity.albumTypeEx?.darwin?.type?.index,
|
||||
entity.albumTypeEx?.darwin?.subtype?.index,
|
||||
];
|
||||
}
|
||||
|
||||
static AssetPathEntity assetPath(Map<String, dynamic> row) {
|
||||
return AssetPathEntity(
|
||||
id: row['path_id'] as String,
|
||||
name: row['name'] as String,
|
||||
albumType: row['album_type'] as int,
|
||||
albumTypeEx: AlbumType(
|
||||
darwin: !Platform.isAndroid
|
||||
? DarwinAlbumType(
|
||||
type: PMDarwinAssetCollectionTypeExt.fromValue(
|
||||
row['ios_album_type'] as int?,
|
||||
),
|
||||
subtype: PMDarwinAssetCollectionSubtypeExt.fromValue(
|
||||
row['darwin_subtype'] as int?,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
253
mobile/apps/photos/lib/db/local/schema.dart
Normal file
253
mobile/apps/photos/lib/db/local/schema.dart
Normal file
@@ -0,0 +1,253 @@
|
||||
import "dart:io";
|
||||
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:sqlite_async/sqlite_async.dart";
|
||||
|
||||
const assetColumns =
|
||||
"id, type, sub_type, width, height, duration_in_sec, orientation, is_fav, title, relative_path, created_at, modified_at, mime_type, latitude, longitude, scan_state";
|
||||
|
||||
const assetUploadQueueColumns =
|
||||
"dest_collection_id, asset_id, path_id, owner_id, manual";
|
||||
const androidAssetState = 1;
|
||||
const androidHashState = 1 << 2;
|
||||
const androidMediaType = 1 << 3;
|
||||
const iOSAssetState = 1;
|
||||
const iOSCloudIdState = 1 << 2;
|
||||
const iOSAssetHashState = 1 << 3;
|
||||
|
||||
final finalState = Platform.isAndroid
|
||||
? (androidAssetState ^ androidHashState ^ androidMediaType)
|
||||
: (iOSAssetState ^ iOSCloudIdState ^ iOSAssetHashState);
|
||||
// Generate the update clause dynamically (excludes 'id')
|
||||
final String updateAssetColumns = assetColumns
|
||||
.split(', ')
|
||||
.where((column) => column != 'id') // Exclude primary key from update
|
||||
.map((column) => '$column = excluded.$column') // Use excluded virtual table
|
||||
.join(', ');
|
||||
|
||||
const devicePathColumns =
|
||||
"path_id, name, album_type, ios_album_type, ios_album_subtype";
|
||||
|
||||
final String updateDevicePathColumns = devicePathColumns
|
||||
.split(', ')
|
||||
.where((column) => column != 'path_id') // Exclude primary key from update
|
||||
.map((column) => '$column = excluded.$column') // Use excluded virtual table
|
||||
.join(', ');
|
||||
|
||||
const String deviceCollectionWithOneAssetQuery = '''
|
||||
WITH latest_per_path AS (
|
||||
SELECT
|
||||
dpa.path_id,
|
||||
MAX(a.created_at) as max_created,
|
||||
count(*) as asset_count
|
||||
FROM
|
||||
device_path_assets dpa
|
||||
JOIN
|
||||
assets a ON dpa.asset_id = a.id
|
||||
|
||||
GROUP BY
|
||||
dpa.path_id
|
||||
),
|
||||
ranked_assets AS (
|
||||
SELECT
|
||||
dpa.path_id,
|
||||
a.*,
|
||||
ROW_NUMBER() OVER (PARTITION BY dpa.path_id ORDER BY a.id) as rn,
|
||||
lpp.asset_count
|
||||
FROM
|
||||
device_path_assets dpa
|
||||
JOIN
|
||||
assets a ON dpa.asset_id = a.id
|
||||
JOIN
|
||||
latest_per_path lpp ON dpa.path_id = lpp.path_id AND a.created_at = lpp.max_created
|
||||
)
|
||||
SELECT
|
||||
dp.*,
|
||||
ra.*,
|
||||
pc.*
|
||||
FROM
|
||||
device_path dp
|
||||
JOIN
|
||||
ranked_assets ra ON dp.path_id = ra.path_id AND ra.rn = 1
|
||||
LEFT JOIN path_backup_config pc
|
||||
on dp.path_id = pc.device_path_id
|
||||
''';
|
||||
|
||||
class LocalAssertsParam {
|
||||
int? limit;
|
||||
int? offset;
|
||||
String? orderByColumn;
|
||||
bool isAsc;
|
||||
(int?, int?)? createAtRange;
|
||||
|
||||
LocalAssertsParam({
|
||||
this.limit,
|
||||
this.offset,
|
||||
this.orderByColumn = "created_at",
|
||||
this.isAsc = false,
|
||||
this.createAtRange,
|
||||
});
|
||||
|
||||
String get orderBy => orderByColumn == null
|
||||
? ""
|
||||
: "ORDER BY $orderByColumn ${isAsc ? "ASC" : "DESC"}";
|
||||
|
||||
String get limitOffset => (limit != null && offset != null)
|
||||
? "LIMIT $limit + OFFSET $offset)"
|
||||
: (limit != null)
|
||||
? "LIMIT $limit"
|
||||
: "";
|
||||
|
||||
String get createAtRangeStr => (createAtRange == null ||
|
||||
createAtRange!.$1 == null)
|
||||
? ""
|
||||
: "(created_at BETWEEN ${createAtRange!.$1} AND ${createAtRange!.$2})";
|
||||
|
||||
String whereClause({bool addWhere = false}) {
|
||||
final where = <String>[];
|
||||
if (createAtRangeStr.isNotEmpty) {
|
||||
where.add(createAtRangeStr);
|
||||
}
|
||||
|
||||
return (where.isEmpty
|
||||
? ""
|
||||
: '${addWhere ? "Where" : ""} ${where.join(" AND ")}') +
|
||||
" " +
|
||||
orderBy +
|
||||
" " +
|
||||
limitOffset;
|
||||
}
|
||||
}
|
||||
|
||||
class LocalDBMigration {
|
||||
static const migrationScripts = [
|
||||
'''
|
||||
CREATE TABLE assets (
|
||||
id TEXT PRIMARY KEY,
|
||||
type INTEGER NOT NULL,
|
||||
sub_type INTEGER NOT NULL,
|
||||
width INTEGER NOT NULL,
|
||||
height INTEGER NOT NULL,
|
||||
duration_in_sec INTEGER NOT NULL,
|
||||
orientation INTEGER NOT NULL,
|
||||
is_fav INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
relative_path TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
modified_at INTEGER NOT NULL,
|
||||
mime_type TEXT,
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
scan_state INTEGER DEFAULT 0,
|
||||
hash TEXT,
|
||||
size INTEGER,
|
||||
os_metadata TEXT DEFAULT '{}'
|
||||
);
|
||||
''',
|
||||
'''
|
||||
CREATE INDEX IF NOT EXISTS assets_created_at ON assets(created_at);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE shared_assets (
|
||||
dest_collection_id INTEGER NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
duration_in_sec INTEGER DEFAULT 0,
|
||||
owner_id INTEGER NOT NULL,
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
PRIMARY KEY (dest_collection_id, id)
|
||||
);
|
||||
''',
|
||||
'''
|
||||
CREATE INDEX IF NOT EXISTS sa_collection_owner ON shared_assets(dest_collection_id, owner_id);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE device_path (
|
||||
path_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
album_type INTEGER NOT NULL,
|
||||
ios_album_type INTEGER,
|
||||
ios_album_subtype INTEGER
|
||||
);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE device_path_assets (
|
||||
path_id TEXT NOT NULL,
|
||||
asset_id TEXT NOT NULL,
|
||||
PRIMARY KEY (path_id, asset_id),
|
||||
FOREIGN KEY (path_id) REFERENCES device_path(path_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE
|
||||
);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE queue (
|
||||
id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
PRIMARY KEY (id, name)
|
||||
);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE path_backup_config(
|
||||
device_path_id TEXT PRIMARY KEY,
|
||||
owner_id INTEGER NOT NULL,
|
||||
collection_id INTEGER,
|
||||
should_backup INTEGER NOT NULL DEFAULT 0,
|
||||
upload_strategy INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE asset_upload_queue (
|
||||
dest_collection_id INTEGER NOT NULL,
|
||||
asset_id TEXT NOT NULL,
|
||||
path_id TEXT,
|
||||
owner_id INTEGER NOT NULL,
|
||||
manual INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (dest_collection_id, asset_id),
|
||||
FOREIGN KEY(asset_id) REFERENCES assets(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_asset_upload_queue_owner_id
|
||||
ON asset_upload_queue(owner_id)
|
||||
WHERE owner_id IS NOT NULL;
|
||||
''',
|
||||
'''
|
||||
CREATE INDEX IF NOT EXISTS assets_created_at_desc ON assets(created_at DESC);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE edited_assets (
|
||||
id String NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
modified_at INTEGER NOT NULL,
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
PRIMARY KEY (id)
|
||||
FOREIGN KEY (id) REFERENCES assets(id) ON DELETE CASCADE
|
||||
);
|
||||
''',
|
||||
];
|
||||
|
||||
static Future<void> migrate(
|
||||
SqliteDatabase database,
|
||||
) async {
|
||||
final result = await database.execute('PRAGMA user_version');
|
||||
await database.execute("PRAGMA foreign_keys = ON");
|
||||
final currentVersion = result[0]['user_version'] as int;
|
||||
final toVersion = migrationScripts.length;
|
||||
|
||||
if (currentVersion < toVersion) {
|
||||
debugPrint("Migrating Local DB from $currentVersion to $toVersion");
|
||||
await database.writeTransaction((tx) async {
|
||||
for (int i = currentVersion + 1; i <= toVersion; i++) {
|
||||
await tx.execute(migrationScripts[i - 1]);
|
||||
}
|
||||
await tx.execute('PRAGMA user_version = $toVersion');
|
||||
});
|
||||
} else if (currentVersion > toVersion) {
|
||||
throw AssertionError(
|
||||
"currentVersion($currentVersion) cannot be greater than toVersion($toVersion)",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
33
mobile/apps/photos/lib/db/local/table/device_albums.dart
Normal file
33
mobile/apps/photos/lib/db/local/table/device_albums.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
import "package:photo_manager/photo_manager.dart";
|
||||
import "package:photos/db/local/db.dart";
|
||||
import "package:photos/db/local/mappers.dart";
|
||||
import "package:photos/db/local/schema.dart";
|
||||
import "package:photos/models/device_collection.dart";
|
||||
import "package:photos/models/file/file.dart";
|
||||
import "package:photos/models/upload_strategy.dart";
|
||||
|
||||
extension DeviceAlbums on LocalDB {
|
||||
Future<List<DeviceCollection>> getDeviceCollections() async {
|
||||
final List<DeviceCollection> collections = [];
|
||||
final rows = await sqliteDB.getAll(deviceCollectionWithOneAssetQuery);
|
||||
for (final row in rows) {
|
||||
final path = LocalDBMappers.assetPath(row);
|
||||
AssetEntity? asset;
|
||||
if (row['id'] != null) {
|
||||
asset = LocalDBMappers.asset(row);
|
||||
}
|
||||
collections.add(
|
||||
DeviceCollection(
|
||||
path,
|
||||
count: row['asset_count'] as int,
|
||||
thumbnail: asset != null ? EnteFile.fromAssetSync(asset) : null,
|
||||
shouldBackup: (row['should_backup'] ?? 0) as int == 1,
|
||||
uploadStrategy:
|
||||
UploadStrategy.values[(row['upload_strategy'] ?? 0) as int],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return collections;
|
||||
}
|
||||
}
|
||||
92
mobile/apps/photos/lib/db/local/table/path_config_table.dart
Normal file
92
mobile/apps/photos/lib/db/local/table/path_config_table.dart
Normal file
@@ -0,0 +1,92 @@
|
||||
import "package:collection/collection.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:photos/db/local/db.dart";
|
||||
import "package:photos/log/devlog.dart";
|
||||
import "package:photos/models/local/path_config.dart";
|
||||
import "package:photos/models/upload_strategy.dart";
|
||||
|
||||
extension PathBackupConfigTable on LocalDB {
|
||||
Future<void> insertOrUpdatePathConfigs(
|
||||
Map<String, bool> pathConfigs,
|
||||
int ownerID,
|
||||
) async {
|
||||
if (pathConfigs.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.forEach(
|
||||
pathConfigs.entries.slices(LocalDB.batchInsertMaxCount), (slice) async {
|
||||
final List<List<Object?>> values =
|
||||
slice.map((e) => [e.key, e.value ? 1 : 0, ownerID]).toList();
|
||||
await sqliteDB.executeBatch(
|
||||
'INSERT INTO path_backup_config (device_path_id, should_backup, owner_id) VALUES (?, ?, ?) ON CONFLICT(device_path_id) DO UPDATE SET should_backup = ?, owner_id = ?',
|
||||
values.map((e) => [e[0], e[1], e[2], e[1], e[2]]).toList(),
|
||||
);
|
||||
});
|
||||
debugPrint(
|
||||
'$runtimeType insertOrUpdatePathConfigs complete in ${stopwatch.elapsed.inMilliseconds}ms for ${pathConfigs.length} paths',
|
||||
);
|
||||
}
|
||||
|
||||
Future<Set<String>> getBackedUpPathIDs(int ownerID) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT device_path_id FROM path_backup_config WHERE should_backup = 1 AND owner_id = ?',
|
||||
[ownerID],
|
||||
);
|
||||
final paths = result.map((row) => row['device_path_id'] as String).toSet();
|
||||
devLog(
|
||||
'$runtimeType getPathsWithBackupEnabled complete in ${stopwatch.elapsed.inMilliseconds}ms',
|
||||
name: 'getPathsWithBackupEnabled',
|
||||
);
|
||||
return paths;
|
||||
}
|
||||
|
||||
// destCollectionWithBackup returns the non-null collection ids
|
||||
// for given ownerID for paths that have backup enabled.
|
||||
Future<Set<int>> destCollectionWithBackup(int ownerID) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT collection_id FROM path_backup_config WHERE should_backup = 1 AND owner_id = ? AND collection_id IS NOT NULL',
|
||||
[ownerID],
|
||||
);
|
||||
final Set<int> collectionIDs =
|
||||
result.map((row) => row['collection_id'] as int).whereNotNull().toSet();
|
||||
devLog(
|
||||
'$runtimeType destCollectionWithBackup complete in ${stopwatch.elapsed.inMilliseconds}ms',
|
||||
name: 'destCollectionWithBackup',
|
||||
);
|
||||
return collectionIDs;
|
||||
}
|
||||
|
||||
Future<void> updateDestConnection(
|
||||
String pathID,
|
||||
int destCollection,
|
||||
int ownerID,
|
||||
) async {
|
||||
await sqliteDB.execute(
|
||||
'UPDATE path_backup_config SET collection_id = ? WHERE device_path_id = ? AND owner_id = ?',
|
||||
[destCollection, pathID, ownerID],
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<PathConfig>> getPathConfigs(int ownerID) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT * FROM path_backup_config WHERE owner_id = ?',
|
||||
[ownerID],
|
||||
);
|
||||
final configs = result.map((row) {
|
||||
return PathConfig(
|
||||
row['device_path_id'] as String,
|
||||
row['owner_id'] as int,
|
||||
row['collection_id'] as int?,
|
||||
(row['should_backup'] as int) == 1,
|
||||
getUploadType(row['upload_strategy'] as int),
|
||||
);
|
||||
}).toList();
|
||||
devLog(
|
||||
'$runtimeType getPathConfigs complete in ${stopwatch.elapsed.inMilliseconds}ms',
|
||||
name: 'getPathConfigs',
|
||||
);
|
||||
return configs;
|
||||
}
|
||||
}
|
||||
69
mobile/apps/photos/lib/db/local/table/shared_assets.dart
Normal file
69
mobile/apps/photos/lib/db/local/table/shared_assets.dart
Normal file
@@ -0,0 +1,69 @@
|
||||
import "package:collection/collection.dart";
|
||||
import "package:photos/db/local/db.dart";
|
||||
import "package:photos/models/local/shared_asset.dart";
|
||||
|
||||
extension SharedAssetsTable on LocalDB {
|
||||
Future<Set<String>> getSharedAssetsID() async {
|
||||
final result = await sqliteDB.getAll('SELECT id FROM shared_assets');
|
||||
return Set.unmodifiable(result.map<String>((row) => row['id'] as String));
|
||||
}
|
||||
|
||||
Future<void> insertSharedAssets(List<SharedAsset> assets) async {
|
||||
if (assets.isEmpty) return;
|
||||
await Future.forEach(
|
||||
assets.slices(LocalDB.batchInsertMaxCount),
|
||||
(slice) async {
|
||||
final List<List<Object?>> values =
|
||||
slice.map((e) => e.rowProps).toList();
|
||||
await sqliteDB.executeBatch(
|
||||
'INSERT INTO shared_assets (id, name, type, creation_time, duration_in_seconds, dest_collection_id, owner_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
values,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<SharedAsset>> getSharedAssets() async {
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT * FROM shared_assets ORDER BY creation_time DESC',
|
||||
);
|
||||
return result.map((row) => SharedAsset.fromRow(row)).toList();
|
||||
}
|
||||
|
||||
Future<List<SharedAsset>> getSharedAssetsByCollection(
|
||||
int collectionID,
|
||||
) async {
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT * FROM shared_assets WHERE dest_collection_id = ? ORDER BY creation_time DESC',
|
||||
[collectionID],
|
||||
);
|
||||
return result.map((row) => SharedAsset.fromRow(row)).toList();
|
||||
}
|
||||
|
||||
Future<void> deleteSharedAssetsByCollection(int collectionID) async {
|
||||
await sqliteDB.execute(
|
||||
'DELETE FROM shared_assets WHERE dest_collection_id = ?',
|
||||
[collectionID],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteSharedAsset(String assetID) async {
|
||||
await sqliteDB.execute(
|
||||
'DELETE FROM shared_assets WHERE id = ?',
|
||||
[assetID],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteSharedAssets(Set<String> assetIDs) async {
|
||||
if (assetIDs.isEmpty) return;
|
||||
await Future.forEach(
|
||||
assetIDs.slices(LocalDB.batchInsertMaxCount),
|
||||
(slice) async {
|
||||
await sqliteDB.executeBatch(
|
||||
'DELETE FROM shared_assets WHERE id = ?',
|
||||
slice.map((id) => [id]).toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
148
mobile/apps/photos/lib/db/local/table/upload_queue_table.dart
Normal file
148
mobile/apps/photos/lib/db/local/table/upload_queue_table.dart
Normal file
@@ -0,0 +1,148 @@
|
||||
import "package:collection/collection.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:photos/db/local/db.dart";
|
||||
import "package:photos/db/local/mappers.dart";
|
||||
import "package:photos/db/local/schema.dart";
|
||||
import "package:photos/models/file/file.dart";
|
||||
import "package:photos/models/local/asset_upload_queue.dart";
|
||||
|
||||
extension UploadQueueTable on LocalDB {
|
||||
Future<Set<String>> getQueueAssetIDs(int ownerID) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT asset_id FROM asset_upload_queue WHERE owner_id = ?',
|
||||
[ownerID],
|
||||
);
|
||||
final assetIDs = result.map((row) => row['asset_id'] as String).toSet();
|
||||
debugPrint(
|
||||
'$runtimeType getQueueAssetIDs complete in ${stopwatch.elapsed.inMilliseconds}ms',
|
||||
);
|
||||
return assetIDs;
|
||||
}
|
||||
|
||||
Future<void> clearMappingsWithDiffPath(
|
||||
int ownerID,
|
||||
Set<String> pathIDs,
|
||||
) async {
|
||||
if (pathIDs.isEmpty) {
|
||||
// delete all mapping with path ids
|
||||
await sqliteDB.execute(
|
||||
'DELETE FROM asset_upload_queue WHERE owner_id = ? AND path_id IS NOT NULL',
|
||||
[ownerID],
|
||||
);
|
||||
} else {
|
||||
// delete mappings where path_id is not null and not in pathIDs
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await sqliteDB.execute(
|
||||
'DELETE FROM asset_upload_queue WHERE owner_id = ? AND path_id IS NOT NULL AND path_id NOT IN (${pathIDs.map((_) => '?').join(',')})',
|
||||
[ownerID, ...pathIDs],
|
||||
);
|
||||
debugPrint(
|
||||
'$runtimeType clearMappingsWithDiffPath complete in ${stopwatch.elapsed.inMilliseconds}ms for ${pathIDs.length} paths',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> existsQueueEntry(AssetUploadQueue entry) async {
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT 1 FROM asset_upload_queue WHERE asset_id = ? AND owner_id = ? AND dest_collection_id = ?',
|
||||
[entry.id, entry.ownerId, entry.destCollectionId],
|
||||
);
|
||||
return result.isNotEmpty;
|
||||
}
|
||||
|
||||
Future<int> delete(AssetUploadQueue entry) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final result = await sqliteDB.execute(
|
||||
'DELETE FROM asset_upload_queue WHERE asset_id = ? AND owner_id = ? and dest_collection_id = ?',
|
||||
[entry.id, entry.ownerId, entry.destCollectionId],
|
||||
);
|
||||
debugPrint(
|
||||
'$runtimeType delete complete in ${stopwatch.elapsed.inMilliseconds}ms for entry: $entry',
|
||||
);
|
||||
return result.isNotEmpty ? result[0]['changes'] as int : 0;
|
||||
}
|
||||
|
||||
Future<List<(AssetUploadQueue, EnteFile)>> getQueueEntriesWithFiles(
|
||||
int ownerID, {
|
||||
int? destCollection,
|
||||
}) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT asset_upload_queue.*, assets.* FROM asset_upload_queue JOIN assets ON assets.id = asset_upload_queue.asset_id WHERE owner_id = ? ${destCollection != null ? 'AND dest_collection_id = ?' : ''} ORDER BY created_at DESC',
|
||||
destCollection != null ? [ownerID, destCollection] : [ownerID],
|
||||
);
|
||||
final entries = result
|
||||
.map(
|
||||
(row) => (
|
||||
AssetUploadQueue(
|
||||
id: row['asset_id'] as String,
|
||||
pathId: row['path_id'] as String?,
|
||||
destCollectionId: row['dest_collection_id'] as int,
|
||||
ownerId: row['owner_id'] as int,
|
||||
manual: (row['manual'] as int) == 1,
|
||||
),
|
||||
EnteFile.fromAssetSync(LocalDBMappers.asset(row)),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
debugPrint(
|
||||
'$runtimeType getQueueEntriesWithFiles complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries',
|
||||
);
|
||||
return entries;
|
||||
}
|
||||
|
||||
Future<List<AssetUploadQueue>> getQueueEntries(
|
||||
int ownerID, {
|
||||
int? destCollection,
|
||||
}) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT asset_upload_queue.*, assets.* FROM asset_upload_queue JOIN assets ON assets.id = asset_upload_queue.asset_id WHERE owner_id = ? ${destCollection != null ? 'AND dest_collection_id = ?' : ''} ORDER BY created_at DESC',
|
||||
destCollection != null ? [ownerID, destCollection] : [ownerID],
|
||||
);
|
||||
final entries = result
|
||||
.map(
|
||||
(row) => AssetUploadQueue(
|
||||
id: row['asset_id'] as String,
|
||||
pathId: row['path_id'] as String?,
|
||||
destCollectionId: row['dest_collection_id'] as int,
|
||||
ownerId: row['owner_id'] as int,
|
||||
manual: (row['manual'] as int) == 1,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
debugPrint(
|
||||
'$runtimeType getQueueEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries',
|
||||
);
|
||||
return entries;
|
||||
}
|
||||
|
||||
Future<void> insertOrUpdateQueue(
|
||||
Set<String> assetIDs,
|
||||
int destCollection,
|
||||
int ownerID, {
|
||||
String? path,
|
||||
bool manual = false,
|
||||
}) async {
|
||||
if (assetIDs.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.forEach(
|
||||
assetIDs.slices(LocalDB.batchInsertMaxCount),
|
||||
(slice) async {
|
||||
final List<List<Object?>> values = slice
|
||||
.map((e) => [destCollection, e, path, ownerID, manual])
|
||||
.toList();
|
||||
await sqliteDB.executeBatch(
|
||||
'INSERT INTO asset_upload_queue ($assetUploadQueueColumns) VALUES(?,?,?,?,?) ON CONFLICT DO UPDATE SET manual = ?, path_id = ?',
|
||||
values
|
||||
.map((e) => [e[0], e[1], e[2], e[3], e[4], manual, path])
|
||||
.toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
debugPrint(
|
||||
'$runtimeType insertOrUpdateQueue complete in ${stopwatch.elapsed.inMilliseconds}ms for ${assetIDs.length} items',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import "package:photos/services/machine_learning/face_ml/face_clustering/face_db
|
||||
abstract class IMLDataDB<T> {
|
||||
Future<void> bulkInsertFaces(List<Face> faces);
|
||||
Future<void> updateFaceIdToClusterId(Map<String, String> faceIDToClusterID);
|
||||
Future<Map<int, int>> faceIndexedFileIds({int minimumMlVersion});
|
||||
Future<Map<T, int>> faceIndexedFileIds({int minimumMlVersion});
|
||||
Future<int> getFaceIndexedFileCount({int minimumMlVersion});
|
||||
Future<Map<String, int>> clusterIdToFaceCount();
|
||||
Future<Set<String>> getPersonIgnoredClusters(String personID);
|
||||
@@ -52,7 +52,7 @@ abstract class IMLDataDB<T> {
|
||||
Future<void> forceUpdateClusterIds(Map<String, String> faceIDToClusterID);
|
||||
Future<void> removeFaceIdToClusterId(Map<String, String> faceIDToClusterID);
|
||||
Future<void> removePerson(String personID);
|
||||
Future<List<FaceDbInfoForClustering>> getFaceInfoForClustering({
|
||||
Future<List<FaceDbInfoForClustering<T>>> getFaceInfoForClustering({
|
||||
int maxFaces,
|
||||
int offset,
|
||||
int batchSize,
|
||||
@@ -112,9 +112,9 @@ abstract class IMLDataDB<T> {
|
||||
});
|
||||
|
||||
Future<List<EmbeddingVector>> getAllClipVectors();
|
||||
Future<Map<int, int>> clipIndexedFileWithVersion();
|
||||
Future<Map<T, int>> clipIndexedFileWithVersion();
|
||||
Future<int> getClipIndexedFileCount({int minimumMlVersion});
|
||||
Future<void> putClip(List<ClipEmbedding> embeddings);
|
||||
Future<void> putClip<T>(List<ClipEmbedding<T>> embeddings);
|
||||
Future<void> deleteClipEmbeddings(List<T> fileIDs);
|
||||
Future<void> deleteClipIndexes();
|
||||
}
|
||||
|
||||
@@ -72,25 +72,26 @@ class ClipVectorDB {
|
||||
_migrationDone = true;
|
||||
}
|
||||
|
||||
Future<void> insertEmbedding({
|
||||
required int fileID,
|
||||
Future<void> insertEmbedding<T>({
|
||||
required T fileID,
|
||||
required List<double> embedding,
|
||||
}) async {
|
||||
final db = await _vectorDB;
|
||||
try {
|
||||
await db.addVector(key: BigInt.from(fileID), vector: embedding);
|
||||
final id = fileID as int;
|
||||
await db.addVector(key: BigInt.from(id), vector: embedding);
|
||||
} catch (e, s) {
|
||||
_logger.severe("Error inserting embedding", e, s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> bulkInsertEmbeddings({
|
||||
required List<int> fileIDs,
|
||||
Future<void> bulkInsertEmbeddings<T>({
|
||||
required List<T> fileIDs,
|
||||
required List<Float32List> embeddings,
|
||||
}) async {
|
||||
final db = await _vectorDB;
|
||||
final bigKeys = Uint64List.fromList(fileIDs);
|
||||
final bigKeys = Uint64List.fromList(fileIDs.map((e) => e as int).toList());
|
||||
try {
|
||||
await db.bulkAddVectors(keys: bigKeys, vectors: embeddings);
|
||||
} catch (e, s) {
|
||||
|
||||
@@ -53,13 +53,13 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
static final MLDataDB instance = MLDataDB._privateConstructor();
|
||||
|
||||
static final _migrationScripts = [
|
||||
createFacesTable,
|
||||
getCreateFacesTable(false),
|
||||
createFaceClustersTable,
|
||||
createClusterPersonTable,
|
||||
createClusterSummaryTable,
|
||||
createNotPersonFeedbackTable,
|
||||
fcClusterIDIndex,
|
||||
createClipEmbeddingsTable,
|
||||
getCreateClipEmbeddingsTable(false),
|
||||
createFileDataTable,
|
||||
createFaceCacheTable,
|
||||
];
|
||||
@@ -80,10 +80,10 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
final asyncDBConnection =
|
||||
SqliteDatabase(path: databaseDirectory, maxReaders: 2);
|
||||
final stopwatch = Stopwatch()..start();
|
||||
_logger.info("MLDataDB: Starting migration");
|
||||
_logger.info("$runtimeType: Starting migration");
|
||||
await migrate(asyncDBConnection, _migrationScripts);
|
||||
_logger.info(
|
||||
"MLDataDB Migration took ${stopwatch.elapsedMilliseconds} ms",
|
||||
"$runtimeType Migration took ${stopwatch.elapsedMilliseconds} ms",
|
||||
);
|
||||
stopwatch.stop();
|
||||
|
||||
@@ -360,10 +360,10 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
(element) => (element[fileIDColumn] as int) == avatarFileId,
|
||||
);
|
||||
if (row != null) {
|
||||
return mapRowToFace(row);
|
||||
return mapRowToFace<int>(row);
|
||||
}
|
||||
}
|
||||
return mapRowToFace(faceMaps.first);
|
||||
return mapRowToFace<int>(faceMaps.first);
|
||||
}
|
||||
}
|
||||
if (clusterID != null) {
|
||||
@@ -411,7 +411,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
if (maps.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return maps.map((e) => mapRowToFace(e)).toList();
|
||||
return maps.map((e) => mapRowToFace<int>(e)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -428,7 +428,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
}
|
||||
final result = <int, List<FaceWithoutEmbedding>>{};
|
||||
for (final map in maps) {
|
||||
final face = mapRowToFaceWithoutEmbedding(map);
|
||||
final face = mapRowToFaceWithoutEmbedding<int>(map);
|
||||
final fileID = map[fileIDColumn] as int;
|
||||
result.putIfAbsent(fileID, () => <FaceWithoutEmbedding>[]).add(face);
|
||||
}
|
||||
@@ -726,7 +726,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<FaceDbInfoForClustering>> getFaceInfoForClustering({
|
||||
Future<List<FaceDbInfoForClustering<int>>> getFaceInfoForClustering({
|
||||
int maxFaces = 20000,
|
||||
int offset = 0,
|
||||
int batchSize = 10000,
|
||||
@@ -738,7 +738,8 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
);
|
||||
final db = await instance.asyncDB;
|
||||
|
||||
final List<FaceDbInfoForClustering> result = <FaceDbInfoForClustering>[];
|
||||
final List<FaceDbInfoForClustering<int>> result =
|
||||
<FaceDbInfoForClustering<int>>[];
|
||||
while (true) {
|
||||
// Query a batch of rows
|
||||
final List<Map<String, dynamic>> maps = await db.getAll(
|
||||
@@ -758,7 +759,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
final faceIdToClusterId = await getFaceIdsToClusterIds(faceIds);
|
||||
for (final map in maps) {
|
||||
final faceID = map[faceIDColumn] as String;
|
||||
final faceInfo = FaceDbInfoForClustering(
|
||||
final faceInfo = FaceDbInfoForClustering<int>(
|
||||
faceID: faceID,
|
||||
clusterId: faceIdToClusterId[faceID],
|
||||
embeddingBytes: map[embeddingColumn] as Uint8List,
|
||||
@@ -1135,7 +1136,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
final db = await instance.asyncDB;
|
||||
if (faces) {
|
||||
await db.execute(deleteFacesTable);
|
||||
await db.execute(createFacesTable);
|
||||
await db.execute(getCreateFacesTable(false));
|
||||
await db.execute(deleteFaceClustersTable);
|
||||
await db.execute(createFaceClustersTable);
|
||||
await db.execute(fcClusterIDIndex);
|
||||
@@ -1335,8 +1336,8 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
"Got ${fileIDs.length} valid embeddings, $weirdCount weird embeddings",
|
||||
);
|
||||
|
||||
await ClipVectorDB.instance
|
||||
.bulkInsertEmbeddings(fileIDs: fileIDs, embeddings: embeddings);
|
||||
await ClipVectorDB.instance.bulkInsertEmbeddings<int>(
|
||||
fileIDs: fileIDs, embeddings: embeddings);
|
||||
_logger.info("Inserted ${fileIDs.length} embeddings to ClipVectorDB");
|
||||
processedCount += fileIDs.length;
|
||||
offset += batchSize;
|
||||
@@ -1396,7 +1397,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> putClip(List<ClipEmbedding> embeddings) async {
|
||||
Future<void> putClip<int>(List<ClipEmbedding<int>> embeddings) async {
|
||||
if (embeddings.isEmpty) return;
|
||||
final db = await instance.asyncDB;
|
||||
if (embeddings.length == 1) {
|
||||
@@ -1406,8 +1407,9 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
);
|
||||
if (flagService.enableVectorDb &&
|
||||
await ClipVectorDB.instance.checkIfMigrationDone()) {
|
||||
final e = embeddings.first.fileID;
|
||||
await ClipVectorDB.instance.insertEmbedding(
|
||||
fileID: embeddings.first.fileID,
|
||||
fileID: e,
|
||||
embedding: embeddings.first.embedding,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import "package:photos/models/ml/face/face.dart";
|
||||
import "package:photos/models/ml/face/face_with_embedding.dart";
|
||||
import "package:photos/models/ml/ml_versions.dart";
|
||||
|
||||
Map<String, dynamic> mapRemoteToFaceDB(Face face) {
|
||||
Map<String, dynamic> mapRemoteToFaceDB<T>(Face<T> face) {
|
||||
return {
|
||||
faceIDColumn: face.faceID,
|
||||
fileIDColumn: face.fileID,
|
||||
@@ -24,10 +24,10 @@ Map<String, dynamic> mapRemoteToFaceDB(Face face) {
|
||||
};
|
||||
}
|
||||
|
||||
Face mapRowToFace(Map<String, dynamic> row) {
|
||||
Face mapRowToFace<T>(Map<String, dynamic> row) {
|
||||
return Face(
|
||||
row[faceIDColumn] as String,
|
||||
row[fileIDColumn] as int,
|
||||
row[fileIDColumn] as T,
|
||||
EVector.fromBuffer(row[embeddingColumn] as List<int>).values,
|
||||
row[faceScore] as double,
|
||||
Detection.fromJson(json.decode(row[faceDetectionColumn] as String)),
|
||||
@@ -39,10 +39,12 @@ Face mapRowToFace(Map<String, dynamic> row) {
|
||||
);
|
||||
}
|
||||
|
||||
FaceWithoutEmbedding mapRowToFaceWithoutEmbedding(Map<String, dynamic> row) {
|
||||
return FaceWithoutEmbedding(
|
||||
FaceWithoutEmbedding<T> mapRowToFaceWithoutEmbedding<T>(
|
||||
Map<String, dynamic> row,
|
||||
) {
|
||||
return FaceWithoutEmbedding<T>(
|
||||
row[faceIDColumn] as String,
|
||||
row[fileIDColumn] as int,
|
||||
row[fileIDColumn] as T,
|
||||
row[faceScore] as double,
|
||||
Detection.fromJson(json.decode(row[faceDetectionColumn] as String)),
|
||||
row[faceBlur] as double,
|
||||
|
||||
1266
mobile/apps/photos/lib/db/ml/offlinedb.dart
Normal file
1266
mobile/apps/photos/lib/db/ml/offlinedb.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -17,8 +17,9 @@ const personIdColumn = 'person_id';
|
||||
const clusterIDColumn = 'cluster_id';
|
||||
const personOrClusterIdColumn = 'person_or_cluster_id';
|
||||
|
||||
const createFacesTable = '''CREATE TABLE IF NOT EXISTS $facesTable (
|
||||
$fileIDColumn INTEGER NOT NULL,
|
||||
String getCreateFacesTable(bool isOfflineDB) {
|
||||
return '''CREATE TABLE IF NOT EXISTS $facesTable (
|
||||
$fileIDColumn ${isOfflineDB ? 'TEXT' : 'INTEGER'} NOT NULL,
|
||||
$faceIDColumn TEXT NOT NULL UNIQUE,
|
||||
$faceDetectionColumn TEXT NOT NULL,
|
||||
$embeddingColumn BLOB NOT NULL,
|
||||
@@ -31,6 +32,7 @@ const createFacesTable = '''CREATE TABLE IF NOT EXISTS $facesTable (
|
||||
PRIMARY KEY($fileIDColumn, $faceIDColumn)
|
||||
);
|
||||
''';
|
||||
}
|
||||
|
||||
const deleteFacesTable = 'DELETE FROM $facesTable';
|
||||
// End of Faces Table Fields & Schema Queries
|
||||
@@ -98,18 +100,20 @@ const deleteNotPersonFeedbackTable = 'DELETE FROM $notPersonFeedback';
|
||||
// ## CLIP EMBEDDINGS TABLE
|
||||
const clipTable = 'clip';
|
||||
|
||||
const createClipEmbeddingsTable = '''
|
||||
CREATE TABLE IF NOT EXISTS $clipTable (
|
||||
$fileIDColumn INTEGER NOT NULL,
|
||||
String getCreateClipEmbeddingsTable(bool isOfflineDB) {
|
||||
return '''CREATE TABLE IF NOT EXISTS $clipTable (
|
||||
$fileIDColumn ${isOfflineDB ? 'TEXT' : 'INTEGER'} NOT NULL,
|
||||
$embeddingColumn BLOB NOT NULL,
|
||||
$mlVersionColumn INTEGER NOT NULL,
|
||||
PRIMARY KEY ($fileIDColumn)
|
||||
PRIMARY KEY($fileIDColumn)
|
||||
);
|
||||
''';
|
||||
''';
|
||||
}
|
||||
|
||||
const deleteClipEmbeddingsTable = 'DELETE FROM $clipTable';
|
||||
|
||||
const fileDataTable = 'filedata';
|
||||
|
||||
const createFileDataTable = '''
|
||||
CREATE TABLE IF NOT EXISTS $fileDataTable (
|
||||
$fileIDColumn INTEGER NOT NULL,
|
||||
|
||||
193
mobile/apps/photos/lib/db/remote/db.dart
Normal file
193
mobile/apps/photos/lib/db/remote/db.dart
Normal file
@@ -0,0 +1,193 @@
|
||||
import "dart:io";
|
||||
|
||||
import "package:collection/collection.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:path/path.dart";
|
||||
import "package:path_provider/path_provider.dart";
|
||||
import "package:photos/db/common/base.dart";
|
||||
import "package:photos/db/remote/mappers.dart";
|
||||
import "package:photos/db/remote/schema.dart";
|
||||
import "package:photos/log/devlog.dart";
|
||||
import "package:photos/models/api/diff/diff.dart";
|
||||
import "package:photos/models/collection/collection.dart";
|
||||
import "package:photos/models/file/remote/asset.dart";
|
||||
import "package:sqlite_async/sqlite_async.dart";
|
||||
|
||||
// ignore: constant_identifier_names
|
||||
enum RemoteTable { collections, collection_files, files, entities, trash }
|
||||
|
||||
class RemoteDB with SqlDbBase {
|
||||
static const _databaseName = "remotex6.db";
|
||||
static const _batchInsertMaxCount = 1000;
|
||||
late final SqliteDatabase _sqliteDB;
|
||||
|
||||
Future<void> init() async {
|
||||
devLog("Starting RemoteDB init");
|
||||
final Directory documentsDirectory =
|
||||
await getApplicationDocumentsDirectory();
|
||||
final String path = join(documentsDirectory.path, _databaseName);
|
||||
|
||||
final db = SqliteDatabase(path: path);
|
||||
await migrate(db, RemoteDBMigration.migrationScripts, onForeignKey: true);
|
||||
_sqliteDB = db;
|
||||
debugPrint("RemoteDB init complete $path");
|
||||
}
|
||||
|
||||
SqliteDatabase get sqliteDB => _sqliteDB;
|
||||
|
||||
Future<List<Collection>> getAllCollections() async {
|
||||
final result = <Collection>[];
|
||||
final cursor = await _sqliteDB.getAll("SELECT * FROM collections");
|
||||
for (final row in cursor) {
|
||||
result.add(Collection.fromRow(row));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> clearAllTables() async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.wait([
|
||||
_sqliteDB.execute('DELETE FROM collections'),
|
||||
_sqliteDB.execute('DELETE FROM collection_files'),
|
||||
_sqliteDB.execute('DELETE FROM files'),
|
||||
_sqliteDB.execute('DELETE FROM files_metadata'),
|
||||
_sqliteDB.execute('DELETE FROM trash'),
|
||||
_sqliteDB.execute('DELETE FROM upload_mapping'),
|
||||
]);
|
||||
debugPrint(
|
||||
'$runtimeType clearAllTables complete in ${stopwatch.elapsed.inMilliseconds}ms',
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<int, int>> getCollectionIDToUpdationTime() async {
|
||||
final result = <int, int>{};
|
||||
final cursor = await _sqliteDB.getAll(
|
||||
"SELECT id, updation_time FROM collections where is_deleted = 0",
|
||||
);
|
||||
for (final row in cursor) {
|
||||
result[row['id'] as int] = row['updation_time'] as int;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<RemoteAsset>> getRemoteAssets() async {
|
||||
final result = <RemoteAsset>[];
|
||||
final cursor = await _sqliteDB.getAll(
|
||||
"SELECT * FROM files",
|
||||
);
|
||||
for (final row in cursor) {
|
||||
result.add(fromFilesRow(row));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> insertCollections(List<Collection> collections) async {
|
||||
if (collections.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.forEach(collections.slices(_batchInsertMaxCount),
|
||||
(slice) async {
|
||||
final List<List<Object?>> values =
|
||||
slice.map((e) => e.rowValiues()).toList();
|
||||
await _sqliteDB.executeBatch(
|
||||
'INSERT INTO collections ($collectionColumns) values($collectionValuePlaceHolder) ON CONFLICT(id) DO UPDATE SET $updateCollectionColumns',
|
||||
values,
|
||||
);
|
||||
});
|
||||
debugPrint(
|
||||
'$runtimeType insertCollections complete in ${stopwatch.elapsed.inMilliseconds}ms for ${collections.length} collections',
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<RemoteAsset>> insertDiffItems(
|
||||
List<DiffItem> items,
|
||||
) async {
|
||||
if (items.isEmpty) return [];
|
||||
final List<RemoteAsset> assets = [];
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.forEach(items.slices(_batchInsertMaxCount), (slice) async {
|
||||
final List<List<Object?>> collectionFileValues = [];
|
||||
final List<List<Object?>> fileValues = [];
|
||||
final List<List<Object?>> fileMetadataValues = [];
|
||||
for (final item in slice) {
|
||||
final rAsset = item.fileItem.toRemoteAsset();
|
||||
collectionFileValues.add(item.collectionFileRowValues());
|
||||
fileMetadataValues.add(item.fileItem.filesMetadataRowValues());
|
||||
fileValues.add(remoteAssetToRow(rAsset));
|
||||
assets.add(rAsset);
|
||||
}
|
||||
await Future.wait([
|
||||
_sqliteDB.executeBatch(
|
||||
'INSERT INTO collection_files ($collectionFilesColumns) values(?, ?, ?, ?, ?, ?) ON CONFLICT(file_id, collection_id) DO UPDATE SET $collectionFilesUpdateColumns',
|
||||
collectionFileValues,
|
||||
),
|
||||
_sqliteDB.executeBatch(
|
||||
'INSERT INTO files ($filesColumns) values(${getParams(23)}) ON CONFLICT(id) DO UPDATE SET $filesUpdateColumns',
|
||||
fileValues,
|
||||
),
|
||||
_sqliteDB.executeBatch(
|
||||
'INSERT INTO files_metadata ($filesMetadataColumns) values(${getParams(5)}) ON CONFLICT(id) DO UPDATE SET $filesMetadataUpdateColumns',
|
||||
fileMetadataValues,
|
||||
),
|
||||
]);
|
||||
});
|
||||
debugPrint(
|
||||
'$runtimeType insertCollectionFilesDiff complete in ${stopwatch.elapsed.inMilliseconds}ms for ${items.length}',
|
||||
);
|
||||
return assets;
|
||||
}
|
||||
|
||||
Future<void> deleteFilesDiff(
|
||||
List<DiffItem> items,
|
||||
) async {
|
||||
final int collectionID = items.first.collectionID;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.forEach(items.slices(_batchInsertMaxCount), (slice) async {
|
||||
await _sqliteDB.execute(
|
||||
'DELETE FROM collection_files WHERE file_id IN (${slice.map((e) => e.fileID).join(',')}) AND collection_id = $collectionID',
|
||||
);
|
||||
});
|
||||
debugPrint(
|
||||
'$runtimeType deleteCollectionFilesDiff complete in ${stopwatch.elapsed.inMilliseconds}ms for ${items.length}',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteEntries<T>(Set<T> ids, RemoteTable table) async {
|
||||
if (ids.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await _sqliteDB.execute(
|
||||
'DELETE FROM ${table.name.toLowerCase()} WHERE id IN (${ids.join(',')})',
|
||||
);
|
||||
debugPrint(
|
||||
'$runtimeType deleteEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${ids.length} $table entries',
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> rowCount(
|
||||
RemoteTable table,
|
||||
) async {
|
||||
final row = await _sqliteDB.get(
|
||||
'SELECT COUNT(*) as count FROM ${table.name}',
|
||||
);
|
||||
return row['count'] as int;
|
||||
}
|
||||
|
||||
Future<Set<T>> _getByIds<T>(
|
||||
Set<int> ids,
|
||||
String table,
|
||||
T Function(
|
||||
Map<String, Object?> row,
|
||||
) mapRow, {
|
||||
String columnName = "id",
|
||||
}) async {
|
||||
final result = <T>{};
|
||||
if (ids.isNotEmpty) {
|
||||
final rows = await _sqliteDB.getAll(
|
||||
'SELECT * from $table where $columnName IN (${ids.join(',')})',
|
||||
);
|
||||
for (final row in rows) {
|
||||
result.add(mapRow(row));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
114
mobile/apps/photos/lib/db/remote/mappers.dart
Normal file
114
mobile/apps/photos/lib/db/remote/mappers.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:photos/models/api/diff/diff.dart";
|
||||
import "package:photos/models/api/diff/trash_time.dart";
|
||||
import "package:photos/models/file/file.dart";
|
||||
import "package:photos/models/file/remote/asset.dart";
|
||||
import "package:photos/models/file/remote/collection_file.dart";
|
||||
import "package:photos/models/file/remote/rl_mapping.dart";
|
||||
import "package:photos/models/location/location.dart";
|
||||
|
||||
RemoteAsset fromTrashRow(Map<String, dynamic> row) {
|
||||
final metadata = Metadata.fromEncodedJson(row['metadata']);
|
||||
final privateMetadata = Metadata.fromEncodedJson(row['priv_metadata']);
|
||||
final publicMetadata = Metadata.fromEncodedJson(row['pub_metadata']);
|
||||
final info = Info.fromEncodedJson(row['info']);
|
||||
return RemoteAsset.fromMetadata(
|
||||
id: row['id'],
|
||||
ownerID: row['owner_id'],
|
||||
thumbHeader: row['thumb_header'],
|
||||
fileHeader: row['file_header'],
|
||||
metadata: metadata!,
|
||||
privateMetadata: privateMetadata,
|
||||
publicMetadata: publicMetadata,
|
||||
info: info,
|
||||
);
|
||||
}
|
||||
|
||||
List<Object?> remoteAssetToRow(RemoteAsset asset) {
|
||||
return [
|
||||
asset.id,
|
||||
asset.ownerID,
|
||||
asset.fileHeader,
|
||||
asset.thumbHeader,
|
||||
asset.creationTime,
|
||||
asset.modificationTime,
|
||||
asset.type,
|
||||
asset.subType,
|
||||
asset.title,
|
||||
asset.fileSize,
|
||||
asset.hash,
|
||||
asset.visibility,
|
||||
asset.durationInSec,
|
||||
asset.location?.latitude,
|
||||
asset.location?.longitude,
|
||||
asset.height,
|
||||
asset.width,
|
||||
asset.noThumb,
|
||||
asset.sv,
|
||||
asset.mediaType,
|
||||
asset.motionVideoIndex,
|
||||
asset.caption,
|
||||
asset.uploaderName,
|
||||
];
|
||||
}
|
||||
|
||||
RemoteAsset fromFilesRow(Map<String, Object?> row) {
|
||||
return RemoteAsset(
|
||||
id: row['id'] as int,
|
||||
ownerID: row['owner_id'] as int,
|
||||
thumbHeader: row['thumb_header'] as Uint8List,
|
||||
fileHeader: row['file_header'] as Uint8List,
|
||||
creationTime: row['creation_time'] as int,
|
||||
modificationTime: row['modification_time'] as int,
|
||||
type: row['type'] as int,
|
||||
subType: row['subtype'] as int,
|
||||
title: row['title'] as String,
|
||||
fileSize: row['size'] as int?,
|
||||
hash: row['hash'] as String?,
|
||||
visibility: row['visibility'] as int?,
|
||||
durationInSec: row['durationInSec'] as int?,
|
||||
location: Location(
|
||||
latitude: (row['lat'] as num?)?.toDouble(),
|
||||
longitude: (row['lng'] as num?)?.toDouble(),
|
||||
),
|
||||
height: row['height'] as int?,
|
||||
width: row['width'] as int?,
|
||||
noThumb: row['no_thumb'] as int?,
|
||||
sv: row['sv'] as int?,
|
||||
mediaType: row['media_type'] as int?,
|
||||
motionVideoIndex: row['motion_video_index'] as int?,
|
||||
caption: row['caption'] as String?,
|
||||
uploaderName: row['uploader_name'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
RLMapping rowToUploadLocalMapping(Map<String, Object?> row) {
|
||||
return RLMapping(
|
||||
remoteUploadID: row['file_id'] as int,
|
||||
localID: row['local_id'] as String,
|
||||
localCloudID: row['local_cloud_id'] as String?,
|
||||
mappingType:
|
||||
MappingTypeExtension.fromName(row['local_mapping_src'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
EnteFile trashRowToEnteFile(Map<String, Object?> row) {
|
||||
final RemoteAsset asset = fromTrashRow(row);
|
||||
final TrashTime time = TrashTime(
|
||||
createdAt: row['created_at'] as int,
|
||||
updatedAt: row['updated_at'] as int,
|
||||
deleteBy: row['delete_by'] as int,
|
||||
);
|
||||
final cf = CollectionFile(
|
||||
fileID: asset.id,
|
||||
collectionID: row['collection_id'] as int,
|
||||
encFileKey: row['enc_key'] as Uint8List,
|
||||
encFileKeyNonce: row['enc_key_nonce'] as Uint8List,
|
||||
updatedAt: time.updatedAt,
|
||||
createdAt: time.createdAt,
|
||||
);
|
||||
final file = EnteFile.fromRemoteAsset(asset, cf);
|
||||
file.trashTime = time;
|
||||
return file;
|
||||
}
|
||||
235
mobile/apps/photos/lib/db/remote/schema.dart
Normal file
235
mobile/apps/photos/lib/db/remote/schema.dart
Normal file
@@ -0,0 +1,235 @@
|
||||
const collectionColumns =
|
||||
'id, owner, enc_key, enc_key_nonce, name, type, local_path, is_deleted, '
|
||||
'updation_time, sharees, public_urls, mmd_encoded_json, '
|
||||
'mmd_ver, pub_mmd_encoded_json, pub_mmd_ver, shared_mmd_json, '
|
||||
'shared_mmd_ver';
|
||||
|
||||
final String updateCollectionColumns = collectionColumns
|
||||
.split(', ')
|
||||
.where((column) => column != 'id') // Exclude primary key from update
|
||||
.map((column) => '$column = excluded.$column') // Use excluded virtual table
|
||||
.join(', ');
|
||||
|
||||
const collectionFilesColumns =
|
||||
'collection_id, file_id, enc_key, enc_key_nonce, created_at, updated_at';
|
||||
|
||||
final String collectionFilesUpdateColumns = collectionFilesColumns
|
||||
.split(', ')
|
||||
.where(
|
||||
(column) =>
|
||||
column != 'collection_id' ||
|
||||
column != 'file_id' ||
|
||||
column != 'created_at',
|
||||
)
|
||||
.map((column) => '$column = excluded.$column') // Use excluded virtual table
|
||||
.join(', ');
|
||||
|
||||
const filesColumns =
|
||||
'id, owner_id, file_header, thumb_header, creation_time, modification_time, '
|
||||
'type, subtype, title, size, hash, visibility, durationInSec, lat, lng, '
|
||||
'height, width, no_thumb, sv, media_type, motion_video_index, caption, uploader_name';
|
||||
|
||||
final String filesUpdateColumns = filesColumns
|
||||
.split(', ')
|
||||
.where((column) => (column != 'id'))
|
||||
.map((column) => '$column = excluded.$column') // Use excluded virtual table
|
||||
.join(', ');
|
||||
|
||||
const filesMetadataColumns = 'id, metadata, priv_metadata, pub_metadata, info';
|
||||
final String filesMetadataUpdateColumns = filesMetadataColumns
|
||||
.split(', ')
|
||||
.where((column) => (column != 'id'))
|
||||
.map((column) => '$column = excluded.$column') // Use excluded virtual table
|
||||
.join(', ');
|
||||
|
||||
const trashedFilesColumns =
|
||||
'id, owner_id, collection_id, enc_key,enc_key_nonce, file_header, thumb_header, metadata, priv_metadata, pub_metadata, info, created_at, updated_at, delete_by';
|
||||
|
||||
final String trashedFilesUpdateColumns = trashedFilesColumns
|
||||
.split(', ')
|
||||
.where((column) => (column != 'id'))
|
||||
.map((column) => '$column = excluded.$column') // Use excluded virtual table
|
||||
.join(', ');
|
||||
|
||||
const uploadLocalMappingColumns =
|
||||
'file_id, local_id, local_cloud_id, local_mapping_src';
|
||||
String collectionValuePlaceHolder =
|
||||
collectionColumns.split(',').map((_) => '?').join(',');
|
||||
|
||||
class RemoteDBMigration {
|
||||
static const migrationScripts = [
|
||||
'''
|
||||
CREATE TABLE collections (
|
||||
id INTEGER PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
enc_key TEXT NOT NULL,
|
||||
enc_key_nonce TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
local_path TEXT,
|
||||
is_deleted INTEGER NOT NULL,
|
||||
updation_time INTEGER NOT NULL,
|
||||
sharees TEXT NOT NULL DEFAULT '[]',
|
||||
public_urls TEXT NOT NULL DEFAULT '[]',
|
||||
mmd_encoded_json TEXT NOT NULL DEFAULT '{}',
|
||||
mmd_ver INTEGER NOT NULL DEFAULT 0,
|
||||
pub_mmd_encoded_json TEXT DEFAULT '{}',
|
||||
pub_mmd_ver INTEGER NOT NULL DEFAULT 0,
|
||||
shared_mmd_json TEXT NOT NULL DEFAULT '{}',
|
||||
shared_mmd_ver INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE collection_files (
|
||||
file_id INTEGER NOT NULL,
|
||||
collection_id INTEGER NOT NULL,
|
||||
enc_key BLOB NOT NULL,
|
||||
enc_key_nonce BLOB NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (file_id, collection_id)
|
||||
);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
owner_id INTEGER NOT NULL,
|
||||
file_header BLOB NOT NULL,
|
||||
thumb_header BLOB NOT NULL,
|
||||
creation_time INTEGER NOT NULL,
|
||||
modification_time INTEGER NOT NULL,
|
||||
type INTEGER NOT NULL,
|
||||
subtype INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
size INTEGER,
|
||||
hash TEXT,
|
||||
visibility integer,
|
||||
durationInSec INTEGER,
|
||||
lat REAL DEFAULT NULL,
|
||||
lng REAL DEFAULT NULL,
|
||||
height INTEGER,
|
||||
width INTEGER,
|
||||
no_thumb INTEGER,
|
||||
sv INTEGER,
|
||||
media_type INTEGER,
|
||||
motion_video_index INTEGER,
|
||||
caption TEXT,
|
||||
uploader_name TEXT
|
||||
)
|
||||
''',
|
||||
'''
|
||||
CREATE INDEX IF NOT EXISTS file_hash_index ON files(hash);
|
||||
''',
|
||||
'''
|
||||
CREATE INDEX IF NOT EXISTS file_creation_time_index ON files(creation_time);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE files_metadata (
|
||||
id INTEGER PRIMARY KEY,
|
||||
metadata TEXT NOT NULL,
|
||||
priv_metadata TEXT,
|
||||
pub_metadata TEXT,
|
||||
info TEXT,
|
||||
FOREIGN KEY (id) REFERENCES files(id) ON DELETE CASCADE
|
||||
)
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE trash (
|
||||
id INTEGER PRIMARY KEY,
|
||||
owner_id INTEGER NOT NULL,
|
||||
collection_id INTEGER NOT NULL,
|
||||
enc_key BLOB NOT NULL,
|
||||
enc_key_nonce BLOB NOT NULL,
|
||||
metadata TEXT NOT NULL,
|
||||
priv_metadata TEXT,
|
||||
pub_metadata TEXT,
|
||||
info TEXT,
|
||||
file_header BLOB NOT NULL,
|
||||
thumb_header BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
delete_by INTEGER NOT NULL
|
||||
)
|
||||
''',
|
||||
'''
|
||||
CREATE TRIGGER delete_orphaned_files
|
||||
AFTER DELETE ON collection_files
|
||||
FOR EACH ROW
|
||||
WHEN (
|
||||
-- Only proceed if this file_id actually existed before deletion
|
||||
OLD.file_id IS NOT NULL
|
||||
-- And only if this was the last reference to the file
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM collection_files
|
||||
WHERE file_id = OLD.file_id
|
||||
)
|
||||
)
|
||||
BEGIN
|
||||
-- Only then delete from files table
|
||||
DELETE FROM files WHERE id = OLD.file_id;
|
||||
END;
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE upload_mapping (
|
||||
file_id INTEGER PRIMARY KEY,
|
||||
local_id TEXT NOT NULL,
|
||||
-- icloud identifier if available
|
||||
local_cloud_id TEXT,
|
||||
local_mapping_src TEXT DEFAULT NULL,
|
||||
FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE
|
||||
)'''
|
||||
];
|
||||
}
|
||||
|
||||
class FilterQueryParam {
|
||||
int? collectionID;
|
||||
int? limit;
|
||||
int? offset;
|
||||
String? orderByColumn;
|
||||
bool isAsc;
|
||||
(int?, int?)? createAtRange;
|
||||
|
||||
FilterQueryParam({
|
||||
this.limit,
|
||||
this.offset,
|
||||
this.collectionID,
|
||||
this.orderByColumn = "creation_time",
|
||||
this.isAsc = false,
|
||||
this.createAtRange,
|
||||
});
|
||||
|
||||
String get orderBy => orderByColumn == null
|
||||
? ""
|
||||
: "ORDER BY $orderByColumn ${isAsc ? "ASC" : "DESC"}";
|
||||
|
||||
String get limitOffset => (limit != null && offset != null)
|
||||
? "LIMIT $limit + OFFSET $offset)"
|
||||
: (limit != null)
|
||||
? "LIMIT $limit"
|
||||
: "";
|
||||
|
||||
String get collectionFilter =>
|
||||
(collectionID == null) ? "" : "collection_id = $collectionID";
|
||||
|
||||
String get createAtRangeStr => (createAtRange == null ||
|
||||
createAtRange!.$1 == null)
|
||||
? ""
|
||||
: "(creation_time BETWEEN ${createAtRange!.$1} AND ${createAtRange!.$2})";
|
||||
|
||||
String whereClause() {
|
||||
final where = <String>[];
|
||||
if (collectionFilter.isNotEmpty) {
|
||||
where.add(collectionFilter);
|
||||
}
|
||||
if (createAtRangeStr.isNotEmpty) {
|
||||
where.add(createAtRangeStr);
|
||||
}
|
||||
|
||||
return (where.isEmpty ? "" : where.join(" AND ")) +
|
||||
" " +
|
||||
orderBy +
|
||||
" " +
|
||||
limitOffset;
|
||||
}
|
||||
}
|
||||
288
mobile/apps/photos/lib/db/remote/table/collection_files.dart
Normal file
288
mobile/apps/photos/lib/db/remote/table/collection_files.dart
Normal file
@@ -0,0 +1,288 @@
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:photos/db/remote/db.dart";
|
||||
import "package:photos/db/remote/schema.dart";
|
||||
import "package:photos/extensions/stop_watch.dart";
|
||||
import "package:photos/models/file/remote/collection_file.dart";
|
||||
|
||||
extension CollectionFiles on RemoteDB {
|
||||
Future<int> getCollectionFileCount(int collectionID) async {
|
||||
final row = await sqliteDB.get(
|
||||
"SELECT COUNT(*) as count FROM collection_files WHERE collection_id = ?",
|
||||
[collectionID],
|
||||
);
|
||||
return row["count"] as int;
|
||||
}
|
||||
|
||||
Future<Set<int>> getUploadedFileIDs(int collectionID) async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT file_id FROM collection_files WHERE collection_id = ?",
|
||||
[collectionID],
|
||||
);
|
||||
final Set<int> fileIDs = {};
|
||||
for (var row in rows) {
|
||||
fileIDs.add(row["file_id"] as int);
|
||||
}
|
||||
return fileIDs;
|
||||
}
|
||||
|
||||
Future<Set<int>> getAllCollectionIDsOfFile(int fileID) async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_id FROM collection_files WHERE file_id = ?",
|
||||
[fileID],
|
||||
);
|
||||
final Set<int> collectionIDs = {};
|
||||
for (var row in rows) {
|
||||
collectionIDs.add(row["collection_id"] as int);
|
||||
}
|
||||
return collectionIDs;
|
||||
}
|
||||
|
||||
Future<Map<int, List<CollectionFile>>> getCollectionFilesGroupedByCollection(
|
||||
List<int> fileIDs,
|
||||
) async {
|
||||
final result = <int, List<CollectionFile>>{};
|
||||
if (fileIDs.isEmpty) {
|
||||
return result;
|
||||
}
|
||||
final inParam = fileIDs.map((id) => "'$id'").join(',');
|
||||
final results = await sqliteDB.getAll(
|
||||
'SELECT * FROM collection_files WHERE file_id IN ($inParam)',
|
||||
);
|
||||
for (final row in results) {
|
||||
final eachFile = CollectionFile.fromMap(row);
|
||||
if (!result.containsKey(eachFile.collectionID)) {
|
||||
result[eachFile.collectionID] = <CollectionFile>[];
|
||||
}
|
||||
result[eachFile.collectionID]!.add(eachFile);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<CollectionFile>> getAllCFForFileIDs(
|
||||
List<int> fileIDs,
|
||||
) async {
|
||||
if (fileIDs.isEmpty) return [];
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT * FROM collection_files WHERE file_id IN (${fileIDs.join(",")})",
|
||||
);
|
||||
return rows
|
||||
.map((row) => CollectionFile.fromMap(row))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<Map<int, int>> getCollectionIdToFileCount(List<int> fileIDs) async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_id, COUNT(*) as count FROM collection_files WHERE file_id IN (${fileIDs.join(",")}) GROUP BY collection_id",
|
||||
);
|
||||
final Map<int, int> collectionIdToFileCount = {};
|
||||
for (var row in rows) {
|
||||
final collectionId = row["collection_id"] as int;
|
||||
final count = row["count"] as int;
|
||||
collectionIdToFileCount[collectionId] = count;
|
||||
}
|
||||
return collectionIdToFileCount;
|
||||
}
|
||||
|
||||
Future<List<CollectionFile>> getCollectionFiles(
|
||||
FilterQueryParam? params,
|
||||
) async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_files.* FROM collection_files JOIN files on collection_files.file_id=files.id WHERE ${params?.whereClause() ?? "order by creation_time desc"}",
|
||||
);
|
||||
return rows
|
||||
.map((row) => CollectionFile.fromMap(row))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<List<CollectionFile>> getCollectionsFiles(
|
||||
Set<int> collectionIDs,
|
||||
) async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_files.* FROM collection_files JOIN files on collection_files.file_id=files.id WHERE collection_id IN (${collectionIDs.join(",")}) ORDER BY creation_time DESC",
|
||||
);
|
||||
return rows
|
||||
.map((row) => CollectionFile.fromMap(row))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<Map<int, CollectionFile>> getFileIdToCollectionFile(
|
||||
List<int> fileIDs,
|
||||
) async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_files.* FROM collection_files JOIN files on collection_files.file_id=files.id WHERE file_id IN (${fileIDs.join(",")})",
|
||||
);
|
||||
final Map<int, CollectionFile> result = {};
|
||||
for (var row in rows) {
|
||||
final entry = CollectionFile.fromMap(row);
|
||||
result[entry.fileID] = entry;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<CollectionFile>> getAllFiles(int userID) {
|
||||
return sqliteDB.getAll(
|
||||
"SELECT collection_files.* FROM collection_files JOIN files ON collection_files.file_id = files.id WHERE files.owner_id = ? ORDER BY files.creation_time DESC",
|
||||
[userID],
|
||||
).then(
|
||||
(rows) => rows.map((row) => CollectionFile.fromMap(row)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<(Set<int>, Map<String, int>)> getUploadAndHash(
|
||||
int collectionID,
|
||||
) async {
|
||||
final results = await sqliteDB.getAll(
|
||||
'SELECT id, hash FROM collection_files JOIN files ON files.id = collection_files.file_id'
|
||||
' WHERE collection_id = ?',
|
||||
[
|
||||
collectionID,
|
||||
],
|
||||
);
|
||||
final ids = <int>{};
|
||||
final hash = <String, int>{};
|
||||
for (final result in results) {
|
||||
ids.add(result['id'] as int);
|
||||
if (result['hash'] != null) {
|
||||
hash[result['hash'] as String] = result['id'] as int;
|
||||
}
|
||||
}
|
||||
return (ids, hash);
|
||||
}
|
||||
|
||||
Future<List<CollectionFile>> ownedFilesWithSameHash(
|
||||
List<String> hashes,
|
||||
int ownerID,
|
||||
) async {
|
||||
if (hashes.isEmpty) return [];
|
||||
final inParam = hashes.map((e) => "'$e'").join(',');
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_files.* FROM collection_files JOIN files ON collection_files.file_id = files.id WHERE files.hash IN ($inParam) AND files.owner_id = ?",
|
||||
[ownerID],
|
||||
);
|
||||
return rows
|
||||
.map((row) => CollectionFile.fromMap(row))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<CollectionFile?> coverFile(
|
||||
int collectionID,
|
||||
int? fileID, {
|
||||
bool sortInAsc = false,
|
||||
}) async {
|
||||
if (fileID != null) {
|
||||
final entry = await getCollectionFileEntry(collectionID, fileID);
|
||||
if (entry != null) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
final sortedRow = await sqliteDB.getOptional(
|
||||
"SELECT collection_files.* FROM collection_files join files on files.id= collection_files.file_id WHERE collection_id = ? ORDER BY files.creation_time ${sortInAsc ? 'ASC' : 'DESC'} LIMIT 1",
|
||||
[collectionID],
|
||||
);
|
||||
if (sortedRow != null) {
|
||||
return CollectionFile.fromMap(sortedRow);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<CollectionFile?> getCollectionFileEntry(
|
||||
int collectionID,
|
||||
int fileID,
|
||||
) async {
|
||||
final row = await sqliteDB.getOptional(
|
||||
"SELECT * FROM collection_files WHERE collection_id = ? AND file_id = ?",
|
||||
[collectionID, fileID],
|
||||
);
|
||||
if (row != null) {
|
||||
return CollectionFile.fromMap(row);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<CollectionFile?> getAnyCollectionEntry(
|
||||
int fileID,
|
||||
) async {
|
||||
final row = await sqliteDB.getAll(
|
||||
"SELECT * FROM collection_files WHERE file_id = ? limit 1",
|
||||
[fileID],
|
||||
);
|
||||
if (row.isNotEmpty) {
|
||||
return CollectionFile.fromMap(row.first);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<List<CollectionFile>> getFilesCreatedWithinDurations(
|
||||
List<List<int>> durations,
|
||||
Set<int> ignoredCollectionIDs, {
|
||||
String order = 'DESC',
|
||||
}) async {
|
||||
final List<CollectionFile> result = [];
|
||||
for (final duration in durations) {
|
||||
final start = duration[0];
|
||||
final end = duration[1];
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_files.* FROM collection_files join files on files.id=collection_files.file_id WHERE files.creation_time BETWEEN ? AND ? AND collection_id NOT IN (${ignoredCollectionIDs.join(",")}) ORDER BY creation_time $order",
|
||||
[start, end],
|
||||
);
|
||||
result.addAll(rows.map((row) => CollectionFile.fromMap(row)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<CollectionFile>> filesWithLocation() {
|
||||
return sqliteDB
|
||||
.getAll(
|
||||
"SELECT collection_files.* FROM collection_files JOIN files ON collection_files.file_id = files.id WHERE files.lat IS NOT NULL and files.lng IS NOT NULL order by files.creation_time desc",
|
||||
)
|
||||
.then(
|
||||
(rows) => rows.map((row) => CollectionFile.fromMap(row)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteFiles(List<int> fileIDs) async {
|
||||
if (fileIDs.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await sqliteDB.execute(
|
||||
"DELETE FROM collection_files WHERE file_id IN (${fileIDs.join(",")})",
|
||||
);
|
||||
debugPrint(
|
||||
'$runtimeType deleteFiles complete in ${stopwatch.elapsed.inMilliseconds}ms for ${fileIDs.length}',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteCollectionFiles(List<int> cIDs) async {
|
||||
if (cIDs.isEmpty) return;
|
||||
await sqliteDB.execute(
|
||||
"DELETE FROM collection_files WHERE collection_id IN (${cIDs.join(",")})",
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteCFEnteries(
|
||||
int collectionID,
|
||||
List<int> fileIDs,
|
||||
) async {
|
||||
if (fileIDs.isEmpty) return;
|
||||
await sqliteDB.execute(
|
||||
"DELETE FROM collection_files WHERE collection_id = ? AND file_id IN (${fileIDs.join(",")})",
|
||||
[collectionID],
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<int, int>> getCollectionIDToMaxCreationTime() async {
|
||||
final enteWatch = EnteWatch("getCollectionIDToMaxCreationTime")..start();
|
||||
final rows = await sqliteDB.getAll(
|
||||
'''SELECT collection_id, MAX(creation_time) as max_creation_time FROM collection_files join files on
|
||||
collection_files.file_id=files.id GROUP BY collection_id''',
|
||||
);
|
||||
final Map<int, int> result = {};
|
||||
for (var row in rows) {
|
||||
final collectionId = row["collection_id"] as int;
|
||||
final maxCreationTime = row["max_creation_time"] as int;
|
||||
result[collectionId] = maxCreationTime;
|
||||
}
|
||||
enteWatch.log("query done");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
156
mobile/apps/photos/lib/db/remote/table/files_table.dart
Normal file
156
mobile/apps/photos/lib/db/remote/table/files_table.dart
Normal file
@@ -0,0 +1,156 @@
|
||||
import "package:photos/db/remote/db.dart";
|
||||
import "package:photos/models/api/diff/diff.dart";
|
||||
import "package:photos/models/file/file_type.dart";
|
||||
|
||||
extension FilesTable on RemoteDB {
|
||||
// For a given userID, return unique uploadedFileId for the given userID
|
||||
Future<List<int>> fileIDsWithMissingSize(int userId) async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT id FROM files WHERE owner_id = ? AND size = -1",
|
||||
[userId],
|
||||
);
|
||||
final result = <int>[];
|
||||
for (final row in rows) {
|
||||
result.add(row['id'] as int);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<Map<int, int>> getIDToCreationTime() async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT id, creation_time FROM files",
|
||||
);
|
||||
final result = <int, int>{};
|
||||
for (final row in rows) {
|
||||
result[row['id'] as int] = row['creation_time'] as int;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<Map<int, Metadata?>> getIDToMetadata(
|
||||
Set<int> ids, {
|
||||
bool private = false,
|
||||
bool public = false,
|
||||
bool metadata = false,
|
||||
}) async {
|
||||
if (ids.isEmpty) return {};
|
||||
|
||||
// Ensure only one parameter is true
|
||||
final trueCount = [private, public, metadata].where((x) => x).length;
|
||||
if (trueCount != 1) {
|
||||
throw ArgumentError(
|
||||
'Exactly one of private, public, or metadata must be true',
|
||||
);
|
||||
}
|
||||
|
||||
final placeholders = List.filled(ids.length, '?').join(',');
|
||||
String column;
|
||||
|
||||
if (private) {
|
||||
column = 'priv_metadata';
|
||||
} else if (public) {
|
||||
column = 'pub_metadata';
|
||||
} else {
|
||||
column = 'metadata';
|
||||
}
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT id, $column FROM files_metadata WHERE id IN ($placeholders)",
|
||||
ids.toList(),
|
||||
);
|
||||
final result = <int, Metadata?>{};
|
||||
for (final row in rows) {
|
||||
final metadata = Metadata.fromEncodedJson(row[column]);
|
||||
result[row['id'] as int] = metadata;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<Set<int>> idsWithSameHashAndType(String hash, int ownerID) {
|
||||
return sqliteDB.getAll(
|
||||
"SELECT id FROM files WHERE hash = ? AND owner_id = ?",
|
||||
[hash, ownerID],
|
||||
).then((rows) {
|
||||
final result = <int>{};
|
||||
for (final row in rows) {
|
||||
result.add(row['id'] as int);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
// updateSizeForUploadIDs takes a map of upploadedFileID and fileSize and
|
||||
// update the fileSize for the given uploadedFileID
|
||||
Future<void> updateSize(
|
||||
Map<int, int> idToSize,
|
||||
) async {
|
||||
final parameterSets = <List<Object?>>[];
|
||||
for (final id in idToSize.keys) {
|
||||
parameterSets.add([idToSize[id], id]);
|
||||
}
|
||||
return sqliteDB.executeBatch(
|
||||
"UPDATE files SET size = ? WHERE id = ?;",
|
||||
parameterSets,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<int>> getAllFilesAfterDate({
|
||||
required FileType fileType,
|
||||
required DateTime beginDate,
|
||||
required int userID,
|
||||
}) async {
|
||||
final results = await sqliteDB.getAll(
|
||||
'''
|
||||
SELECT files.id FROM files join upload_mapping
|
||||
ON files.id = upload_mapping.file_id
|
||||
WHERE file_type = ?
|
||||
AND creation_time > ?
|
||||
AND owner_id = ?
|
||||
AND (size IS NOT NULL AND size <= 524288000)
|
||||
AND (durationInSec IS NOT NULL AND (durationInSec <= 60 AND durationInSec > 0))
|
||||
''',
|
||||
[getInt(fileType), beginDate.microsecondsSinceEpoch, userID],
|
||||
);
|
||||
final fileIDs = <int>[];
|
||||
for (final row in results) {
|
||||
fileIDs.add(row['id'] as int);
|
||||
}
|
||||
return fileIDs;
|
||||
}
|
||||
|
||||
Future<Map<int, List<(int, Metadata?)>>> getNotificationCandidate(
|
||||
List<int> collectionIDs,
|
||||
int lastAppOpen,
|
||||
) async {
|
||||
if (collectionIDs.isEmpty) return {};
|
||||
final placeholders = List.filled(collectionIDs.length, '?').join(',');
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_id, files.owner_id, metadata FROM collection_files join files ON collection_files.file_id = files.id WHERE collection_id IN ($placeholders) AND collection_files.created_at > ?",
|
||||
[...collectionIDs, lastAppOpen],
|
||||
);
|
||||
final result = <int, List<(int, Metadata?)>>{};
|
||||
for (final row in rows) {
|
||||
final collectionID = row['collection_id'] as int;
|
||||
final ownerID = row['owner_id'] as int;
|
||||
final metadata = Metadata.fromEncodedJson(row['metadata']);
|
||||
result.putIfAbsent(collectionID, () => []).add((ownerID, metadata));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<int> getFilesCountByVisibility(
|
||||
int visibility,
|
||||
int ownerID,
|
||||
Set<int> hiddenCollections,
|
||||
) async {
|
||||
String subQuery = '';
|
||||
if (hiddenCollections.isNotEmpty) {
|
||||
subQuery =
|
||||
'AND id NOT IN (SELECT file_id FROM collection_files WHERE collection_id IN (${hiddenCollections.join(',')}))';
|
||||
}
|
||||
final row = await sqliteDB.get(
|
||||
'SELECT COUNT(id) as count FROM files WHERE visibility = ? AND owner_id = ? $subQuery',
|
||||
[visibility, ownerID],
|
||||
);
|
||||
return row['count'] as int;
|
||||
}
|
||||
}
|
||||
119
mobile/apps/photos/lib/db/remote/table/mapping_table.dart
Normal file
119
mobile/apps/photos/lib/db/remote/table/mapping_table.dart
Normal file
@@ -0,0 +1,119 @@
|
||||
import "package:collection/collection.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:photos/db/remote/db.dart";
|
||||
import "package:photos/db/remote/mappers.dart";
|
||||
import "package:photos/db/remote/schema.dart";
|
||||
import "package:photos/models/backup_status.dart";
|
||||
import "package:photos/models/file/remote/rl_mapping.dart";
|
||||
|
||||
extension UploadMappingTable on RemoteDB {
|
||||
Future<void> insertMappings(List<RLMapping> mappings) async {
|
||||
if (mappings.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.forEach(mappings.slices(1000), (slice) async {
|
||||
final List<List<Object?>> values = slice.map((e) => e.rowValues).toList();
|
||||
await sqliteDB.executeBatch(
|
||||
'INSERT INTO upload_mapping ($uploadLocalMappingColumns) values(?,?,?,?)',
|
||||
values,
|
||||
);
|
||||
});
|
||||
debugPrint(
|
||||
'$runtimeType insertMappings complete in ${stopwatch.elapsed.inMilliseconds}ms for ${mappings.length} mappings',
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<RLMapping>> getMappings() async {
|
||||
final result = <RLMapping>[];
|
||||
final cursor = await sqliteDB.getAll("SELECT * FROM upload_mapping");
|
||||
for (final row in cursor) {
|
||||
result.add(rowToUploadLocalMapping(row));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> deleteMappingsForLocalIDs(Set<String> localIDs) async {
|
||||
if (localIDs.isEmpty) return;
|
||||
final placeholders = List.filled(localIDs.length, '?').join(',');
|
||||
await sqliteDB.execute(
|
||||
'DELETE FROM upload_mapping WHERE local_id IN ($placeholders)',
|
||||
localIDs.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, RLMapping>> getLocalIDToMappingForActiveFiles() async {
|
||||
final result = <String, RLMapping>{};
|
||||
final cursor = await sqliteDB.getAll(
|
||||
"SELECT * FROM upload_mapping join files on upload_mapping.file_id = files.id",
|
||||
);
|
||||
for (final row in cursor) {
|
||||
final mapping = rowToUploadLocalMapping(row);
|
||||
result[mapping.localID] = mapping;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// getLocalIDsForUser returns information about the localIDs that have been
|
||||
// uploaded for the given userID. If the localIDSInGivenPath is not null,
|
||||
// it will only return the localIDs that are in the given path.
|
||||
Future<BackedUpFileIDs> getLocalIDsForUser(
|
||||
int userID,
|
||||
Set<String>? localIDSInGivenPath,
|
||||
) async {
|
||||
final results = await sqliteDB.getAll(
|
||||
'SELECT local_id, files.id, size FROM upload_mapping join files on upload_mapping.file_id = files.id WHERE owner_id = ?',
|
||||
[userID],
|
||||
);
|
||||
|
||||
final Set<String> localIDs = <String>{};
|
||||
final Set<int> uploadedIDs = <int>{};
|
||||
int localSize = 0;
|
||||
for (final result in results) {
|
||||
final String localID = result['local_id'] as String;
|
||||
if (localIDSInGivenPath != null &&
|
||||
!localIDSInGivenPath.contains(localID)) {
|
||||
continue; // Skip if not in the given path
|
||||
}
|
||||
final int? fileSize = result['size'] as int?;
|
||||
if (!localIDs.contains(localID) && fileSize != null) {
|
||||
localSize += fileSize;
|
||||
}
|
||||
localIDs.add(localID);
|
||||
uploadedIDs.add(result['id'] as int);
|
||||
}
|
||||
return BackedUpFileIDs(localIDs.toList(), uploadedIDs.toList(), localSize);
|
||||
}
|
||||
|
||||
Future<Set<String>> getLocalIDsWithMapping(List<String> localIDs) async {
|
||||
if (localIDs.isEmpty) return {};
|
||||
final placeholders = List.filled(localIDs.length, '?').join(',');
|
||||
final cursor = await sqliteDB.getAll(
|
||||
'SELECT local_id FROM upload_mapping join files on upload_mapping.file_id = files.id WHERE local_id IN ($placeholders)',
|
||||
localIDs,
|
||||
);
|
||||
return cursor.map((row) => row['local_id'] as String).toSet();
|
||||
}
|
||||
|
||||
Future<Map<int, String>> getFileIDToLocalIDMapping(List<int> fileIDs) async {
|
||||
if (fileIDs.isEmpty) return {};
|
||||
final placeholders = List.filled(fileIDs.length, '?').join(',');
|
||||
final cursor = await sqliteDB.getAll(
|
||||
'SELECT file_id, local_id FROM upload_mapping WHERE file_id IN ($placeholders)',
|
||||
fileIDs,
|
||||
);
|
||||
return Map.fromEntries(
|
||||
cursor.map(
|
||||
(row) => MapEntry(row['file_id'] as int, row['local_id'] as String),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Set<int>> getFilesWithMapping(List<int> fileIDs) async {
|
||||
if (fileIDs.isEmpty) return {};
|
||||
final placeholders = List.filled(fileIDs.length, '?').join(',');
|
||||
final cursor = await sqliteDB.getAll(
|
||||
'SELECT file_id FROM upload_mapping WHERE file_id IN ($placeholders)',
|
||||
fileIDs,
|
||||
);
|
||||
return cursor.map((row) => row['file_id'] as int).toSet();
|
||||
}
|
||||
}
|
||||
49
mobile/apps/photos/lib/db/remote/table/trash.dart
Normal file
49
mobile/apps/photos/lib/db/remote/table/trash.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
import "package:collection/collection.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:photos/db/remote/db.dart";
|
||||
import "package:photos/db/remote/mappers.dart";
|
||||
import "package:photos/db/remote/schema.dart";
|
||||
import "package:photos/models/api/diff/diff.dart";
|
||||
import "package:photos/models/file/file.dart";
|
||||
|
||||
extension TrashTable on RemoteDB {
|
||||
Future<void> insertTrashDiffItems(List<DiffItem> items) async {
|
||||
if (items.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.forEach(items.slices(1000), (slice) async {
|
||||
final List<List<Object?>> trashRowValues = [];
|
||||
for (final item in slice) {
|
||||
trashRowValues.add(item.trashRowValues());
|
||||
}
|
||||
await Future.wait([
|
||||
sqliteDB.executeBatch(
|
||||
'INSERT INTO trash ($trashedFilesColumns) values(${getParams(14)})',
|
||||
trashRowValues,
|
||||
),
|
||||
]);
|
||||
});
|
||||
debugPrint(
|
||||
'$runtimeType insertCollectionFilesDiff complete in ${stopwatch.elapsed.inMilliseconds}ms for ${items.length}',
|
||||
);
|
||||
}
|
||||
|
||||
// removes the items and returns the number of items removed
|
||||
Future<int> removeTrashItems(List<int> ids) async {
|
||||
if (ids.isEmpty) return 0;
|
||||
final result = await sqliteDB.execute(
|
||||
'DELETE FROM trash WHERE id IN (${ids.join(",")})',
|
||||
);
|
||||
return result.isNotEmpty ? result.first['changes'] as int : 0;
|
||||
}
|
||||
|
||||
Future<List<EnteFile>> getTrashFiles() async {
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT * FROM trash',
|
||||
);
|
||||
return result.map((e) => trashRowToEnteFile(e)).toList();
|
||||
}
|
||||
|
||||
Future<void> clearTrash() async {
|
||||
await sqliteDB.execute('DELETE FROM trash');
|
||||
}
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:photos/models/file/trash_file.dart';
|
||||
import 'package:photos/models/file_load_result.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
// The TrashDB doesn't need to flatten and store all attributes of a file.
|
||||
// Before adding any other column, we should evaluate if we need to query on that
|
||||
// column or not while showing trashed items. Even if we miss storing any new attributes,
|
||||
// during restore, all file attributes will be fetched & stored as required.
|
||||
class TrashDB {
|
||||
static const _databaseName = "ente.trash.db";
|
||||
static const _databaseVersion = 1;
|
||||
static final Logger _logger = Logger("TrashDB");
|
||||
static const tableName = 'trash';
|
||||
|
||||
static const columnUploadedFileID = 'uploaded_file_id';
|
||||
static const columnCollectionID = 'collection_id';
|
||||
static const columnOwnerID = 'owner_id';
|
||||
static const columnTrashUpdatedAt = 't_updated_at';
|
||||
static const columnTrashDeleteBy = 't_delete_by';
|
||||
static const columnEncryptedKey = 'encrypted_key';
|
||||
static const columnKeyDecryptionNonce = 'key_decryption_nonce';
|
||||
static const columnFileDecryptionHeader = 'file_decryption_header';
|
||||
static const columnThumbnailDecryptionHeader = 'thumbnail_decryption_header';
|
||||
static const columnUpdationTime = 'updation_time';
|
||||
|
||||
static const columnCreationTime = 'creation_time';
|
||||
static const columnLocalID = 'local_id';
|
||||
|
||||
// standard file metadata, which isn't editable
|
||||
static const columnFileMetadata = 'file_metadata';
|
||||
|
||||
static const columnMMdEncodedJson = 'mmd_encoded_json';
|
||||
static const columnMMdVersion = 'mmd_ver';
|
||||
|
||||
static const columnPubMMdEncodedJson = 'pub_mmd_encoded_json';
|
||||
static const columnPubMMdVersion = 'pub_mmd_ver';
|
||||
|
||||
Future _onCreate(Database db, int version) async {
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $tableName (
|
||||
$columnUploadedFileID INTEGER PRIMARY KEY NOT NULL,
|
||||
$columnCollectionID INTEGER NOT NULL,
|
||||
$columnOwnerID INTEGER,
|
||||
$columnTrashUpdatedAt INTEGER NOT NULL,
|
||||
$columnTrashDeleteBy INTEGER NOT NULL,
|
||||
$columnEncryptedKey TEXT,
|
||||
$columnKeyDecryptionNonce TEXT,
|
||||
$columnFileDecryptionHeader TEXT,
|
||||
$columnThumbnailDecryptionHeader TEXT,
|
||||
$columnUpdationTime INTEGER,
|
||||
$columnLocalID TEXT,
|
||||
$columnCreationTime INTEGER NOT NULL,
|
||||
$columnFileMetadata TEXT DEFAULT '{}',
|
||||
$columnMMdEncodedJson TEXT DEFAULT '{}',
|
||||
$columnMMdVersion INTEGER DEFAULT 0,
|
||||
$columnPubMMdEncodedJson TEXT DEFAULT '{}',
|
||||
$columnPubMMdVersion INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS creation_time_index ON $tableName($columnCreationTime);
|
||||
CREATE INDEX IF NOT EXISTS delete_by_time_index ON $tableName($columnTrashDeleteBy);
|
||||
CREATE INDEX IF NOT EXISTS updated_at_time_index ON $tableName($columnTrashUpdatedAt);
|
||||
''',
|
||||
);
|
||||
}
|
||||
|
||||
TrashDB._privateConstructor();
|
||||
|
||||
static final TrashDB instance = TrashDB._privateConstructor();
|
||||
|
||||
// only have a single app-wide reference to the database
|
||||
static Future<Database>? _dbFuture;
|
||||
|
||||
Future<Database> get database async {
|
||||
// lazily instantiate the db the first time it is accessed
|
||||
_dbFuture ??= _initDatabase();
|
||||
return _dbFuture!;
|
||||
}
|
||||
|
||||
// this opens the database (and creates it if it doesn't exist)
|
||||
Future<Database> _initDatabase() async {
|
||||
final Directory documentsDirectory =
|
||||
await getApplicationDocumentsDirectory();
|
||||
final String path = join(documentsDirectory.path, _databaseName);
|
||||
_logger.info("DB path " + path);
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: _databaseVersion,
|
||||
onCreate: _onCreate,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clearTable() async {
|
||||
final db = await instance.database;
|
||||
await db.delete(tableName);
|
||||
}
|
||||
|
||||
Future<int> count() async {
|
||||
final db = await instance.database;
|
||||
final count = Sqflite.firstIntValue(
|
||||
await db.rawQuery('SELECT COUNT(*) FROM $tableName'),
|
||||
);
|
||||
return count ?? 0;
|
||||
}
|
||||
|
||||
Future<void> insertMultiple(List<TrashFile> trashFiles) async {
|
||||
final startTime = DateTime.now();
|
||||
final db = await instance.database;
|
||||
var batch = db.batch();
|
||||
int batchCounter = 0;
|
||||
for (TrashFile trash in trashFiles) {
|
||||
if (batchCounter == 400) {
|
||||
await batch.commit(noResult: true);
|
||||
batch = db.batch();
|
||||
batchCounter = 0;
|
||||
}
|
||||
batch.insert(
|
||||
tableName,
|
||||
_getRowForTrash(trash),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
batchCounter++;
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
final endTime = DateTime.now();
|
||||
final duration = Duration(
|
||||
microseconds:
|
||||
endTime.microsecondsSinceEpoch - startTime.microsecondsSinceEpoch,
|
||||
);
|
||||
_logger.info(
|
||||
"Batch insert of " +
|
||||
trashFiles.length.toString() +
|
||||
" took " +
|
||||
duration.inMilliseconds.toString() +
|
||||
"ms.",
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> delete(List<int> uploadedFileIDs) async {
|
||||
final db = await instance.database;
|
||||
return db.delete(
|
||||
tableName,
|
||||
where: '$columnUploadedFileID IN (${uploadedFileIDs.join(', ')})',
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> update(TrashFile file) async {
|
||||
final db = await instance.database;
|
||||
return await db.update(
|
||||
tableName,
|
||||
_getRowForTrash(file),
|
||||
where: '$columnUploadedFileID = ?',
|
||||
whereArgs: [file.uploadedFileID],
|
||||
);
|
||||
}
|
||||
|
||||
Future<FileLoadResult> getTrashedFiles(
|
||||
int startTime,
|
||||
int endTime, {
|
||||
int? limit,
|
||||
bool? asc,
|
||||
}) async {
|
||||
final db = await instance.database;
|
||||
final order = (asc ?? false ? 'ASC' : 'DESC');
|
||||
final results = await db.query(
|
||||
tableName,
|
||||
where: '$columnCreationTime >= ? AND $columnCreationTime <= ?',
|
||||
whereArgs: [startTime, endTime],
|
||||
orderBy: '$columnCreationTime ' + order,
|
||||
limit: limit,
|
||||
);
|
||||
final files = _convertToFiles(results);
|
||||
return FileLoadResult(files, files.length == limit);
|
||||
}
|
||||
|
||||
List<TrashFile> _convertToFiles(List<Map<String, dynamic>> results) {
|
||||
final List<TrashFile> trashedFiles = [];
|
||||
for (final result in results) {
|
||||
trashedFiles.add(_getTrashFromRow(result));
|
||||
}
|
||||
return trashedFiles;
|
||||
}
|
||||
|
||||
TrashFile _getTrashFromRow(Map<String, dynamic> row) {
|
||||
final trashFile = TrashFile();
|
||||
trashFile.updateAt = row[columnTrashUpdatedAt];
|
||||
trashFile.deleteBy = row[columnTrashDeleteBy];
|
||||
trashFile.uploadedFileID = row[columnUploadedFileID];
|
||||
// dirty hack to ensure that the file_downloads & cache mechanism works
|
||||
trashFile.generatedID = -1 * trashFile.uploadedFileID!;
|
||||
trashFile.ownerID = row[columnOwnerID];
|
||||
trashFile.collectionID =
|
||||
row[columnCollectionID] == -1 ? null : row[columnCollectionID];
|
||||
trashFile.encryptedKey = row[columnEncryptedKey];
|
||||
trashFile.keyDecryptionNonce = row[columnKeyDecryptionNonce];
|
||||
trashFile.fileDecryptionHeader = row[columnFileDecryptionHeader];
|
||||
trashFile.thumbnailDecryptionHeader = row[columnThumbnailDecryptionHeader];
|
||||
trashFile.updationTime = row[columnUpdationTime] ?? 0;
|
||||
trashFile.creationTime = row[columnCreationTime];
|
||||
final fileMetadata = row[columnFileMetadata] ?? '{}';
|
||||
trashFile.applyMetadata(jsonDecode(fileMetadata));
|
||||
trashFile.localID = row[columnLocalID];
|
||||
|
||||
trashFile.mMdVersion = row[columnMMdVersion] ?? 0;
|
||||
trashFile.mMdEncodedJson = row[columnMMdEncodedJson] ?? '{}';
|
||||
|
||||
trashFile.pubMmdVersion = row[columnPubMMdVersion] ?? 0;
|
||||
trashFile.pubMmdEncodedJson = row[columnPubMMdEncodedJson] ?? '{}';
|
||||
|
||||
if (trashFile.pubMagicMetadata != null &&
|
||||
trashFile.pubMagicMetadata!.editedTime != null) {
|
||||
// override existing creationTime to avoid re-writing all queries related
|
||||
// to loading the gallery
|
||||
row[columnCreationTime] = trashFile.pubMagicMetadata!.editedTime!;
|
||||
}
|
||||
|
||||
return trashFile;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _getRowForTrash(TrashFile trash) {
|
||||
final row = <String, dynamic>{};
|
||||
row[columnTrashUpdatedAt] = trash.updateAt;
|
||||
row[columnTrashDeleteBy] = trash.deleteBy;
|
||||
row[columnUploadedFileID] = trash.uploadedFileID;
|
||||
row[columnCollectionID] = trash.collectionID;
|
||||
row[columnOwnerID] = trash.ownerID;
|
||||
row[columnEncryptedKey] = trash.encryptedKey;
|
||||
row[columnKeyDecryptionNonce] = trash.keyDecryptionNonce;
|
||||
row[columnFileDecryptionHeader] = trash.fileDecryptionHeader;
|
||||
row[columnThumbnailDecryptionHeader] = trash.thumbnailDecryptionHeader;
|
||||
row[columnUpdationTime] = trash.updationTime;
|
||||
|
||||
row[columnLocalID] = trash.localID;
|
||||
row[columnCreationTime] = trash.creationTime;
|
||||
row[columnFileMetadata] = jsonEncode(trash.metadata);
|
||||
|
||||
row[columnMMdVersion] = trash.mMdVersion;
|
||||
row[columnMMdEncodedJson] = trash.mMdEncodedJson ?? '{}';
|
||||
|
||||
row[columnPubMMdVersion] = trash.pubMmdVersion;
|
||||
row[columnPubMMdEncodedJson] = trash.pubMmdEncodedJson ?? '{}';
|
||||
return row;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import "package:photos/events/event.dart";
|
||||
|
||||
class FileCaptionUpdatedEvent extends Event {
|
||||
final int fileGeneratedID;
|
||||
final String fileTag;
|
||||
|
||||
FileCaptionUpdatedEvent(this.fileGeneratedID);
|
||||
FileCaptionUpdatedEvent(this.fileTag);
|
||||
}
|
||||
|
||||
10
mobile/apps/photos/lib/events/v1/LocalAssetChangedEvent.dart
Normal file
10
mobile/apps/photos/lib/events/v1/LocalAssetChangedEvent.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import "package:photos/events/event.dart";
|
||||
|
||||
class LocalAssetChangedEvent extends Event {
|
||||
final String source;
|
||||
|
||||
LocalAssetChangedEvent(this.source);
|
||||
|
||||
@override
|
||||
String get reason => '$runtimeType{"via": $source}';
|
||||
}
|
||||
@@ -39,20 +39,26 @@ class EnteWatch extends Stopwatch {
|
||||
class TimeLogger {
|
||||
final String context;
|
||||
final int logThreshold;
|
||||
DateTime _start;
|
||||
TimeLogger({this.context = "TLog", this.logThreshold = 5})
|
||||
final DateTime _start;
|
||||
DateTime _toStringStart = DateTime.now();
|
||||
TimeLogger({this.context = "TLog:", this.logThreshold = 5})
|
||||
: _start = DateTime.now();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final int diff = DateTime.now().difference(_start).inMilliseconds;
|
||||
final int diff = DateTime.now().difference(_toStringStart).inMilliseconds;
|
||||
late String res;
|
||||
if (diff > logThreshold) {
|
||||
res = "[$context: $diff ms]";
|
||||
res = "[$context$diff ms]";
|
||||
} else {
|
||||
res = "[]";
|
||||
}
|
||||
_start = DateTime.now();
|
||||
_toStringStart = DateTime.now();
|
||||
return res;
|
||||
}
|
||||
|
||||
String get elapsed {
|
||||
final int diff = DateTime.now().difference(_start).inMilliseconds;
|
||||
return "[$context$diff ms]";
|
||||
}
|
||||
}
|
||||
|
||||
90
mobile/apps/photos/lib/image/in_memory_image_cache.dart
Normal file
90
mobile/apps/photos/lib/image/in_memory_image_cache.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:photos/core/cache/lru_map.dart";
|
||||
import "package:photos/models/file/file.dart";
|
||||
|
||||
// Singleton instance for global access
|
||||
final enteImageCache = InMemoryImageCache._instance;
|
||||
|
||||
class InMemoryImageCache {
|
||||
static final InMemoryImageCache _instance = InMemoryImageCache._();
|
||||
|
||||
// Private constructor for singleton
|
||||
InMemoryImageCache._();
|
||||
|
||||
// Supported dimensions with associated cache sizes
|
||||
static const Map<int, int> _cacheSizes = {
|
||||
32: 5000, // Small: 32*32 = 1024 bytes * 5000 = 6.25MB
|
||||
256: 2000, // Medium: 256*256 = 65536 bytes * 2000 = 128MB
|
||||
512: 100, // Large: 512*512 = 262144 bytes * 100 = 25MB
|
||||
};
|
||||
|
||||
// Cache instances for each dimension
|
||||
final Map<int, LRUMap<String, Uint8List?>> _caches = {
|
||||
32: LRUMap<String, Uint8List?>(5000),
|
||||
256: LRUMap<String, Uint8List?>(2000),
|
||||
512: LRUMap<String, Uint8List?>(100),
|
||||
};
|
||||
|
||||
/// Gets a thumbnail for a file at the specified dimension
|
||||
Uint8List? getThumb(EnteFile file, int dimension) {
|
||||
return _getFromCache(file.cacheKey(), dimension);
|
||||
}
|
||||
|
||||
/// Gets a thumbnail by ID at the specified dimension
|
||||
Uint8List? getThumbByID(String id, int dimension) {
|
||||
return _getFromCache(id, dimension);
|
||||
}
|
||||
|
||||
/// Stores a thumbnail for a file at the specified dimension
|
||||
void putThumb(EnteFile file, Uint8List? imageData, int dimension) {
|
||||
_putInCache(file.cacheKey(), imageData, dimension);
|
||||
}
|
||||
|
||||
/// Stores a thumbnail by ID at the specified dimension
|
||||
void putThumbByID(String id, Uint8List? imageData, int dimension) {
|
||||
_putInCache(id, imageData, dimension);
|
||||
}
|
||||
|
||||
/// Checks if a thumbnail exists for a file at the specified dimension
|
||||
bool containsThumb(EnteFile file, int dimension) {
|
||||
return _isCached(file.cacheKey(), dimension);
|
||||
}
|
||||
|
||||
void clearCache(EnteFile file) {
|
||||
_caches.forEach((_, cache) {
|
||||
cache.remove(file.cacheKey());
|
||||
});
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
Uint8List? _getFromCache(String key, int dimension) {
|
||||
if (_isValidDimension(dimension)) {
|
||||
return _caches[dimension]?.get(key);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _putInCache(String key, Uint8List? imageData, int dimension) {
|
||||
if (_isValidDimension(dimension)) {
|
||||
_caches[dimension]?.put(key, imageData);
|
||||
} else {
|
||||
debugPrint("Unsupported dimension: $dimension");
|
||||
}
|
||||
}
|
||||
|
||||
bool _isCached(String key, int dimension) {
|
||||
if (_isValidDimension(dimension)) {
|
||||
return _caches[dimension]?.containsKey(key) ?? false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _isValidDimension(int dimension) {
|
||||
if (_caches.containsKey(dimension)) {
|
||||
return true;
|
||||
}
|
||||
debugPrint("Invalid dimension: $dimension");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
193
mobile/apps/photos/lib/image/provider/local_thumbnail_img.dart
Normal file
193
mobile/apps/photos/lib/image/provider/local_thumbnail_img.dart
Normal file
@@ -0,0 +1,193 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import "package:equatable/equatable.dart";
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import "package:photos/image/in_memory_image_cache.dart";
|
||||
import "package:photos/utils/standalone/task_queue.dart";
|
||||
|
||||
final thumbnailQueue = TaskQueue<String>(
|
||||
maxConcurrentTasks: 15,
|
||||
taskTimeout: const Duration(minutes: 1),
|
||||
maxQueueSize: 200, // Limit the queue to 50 pending tasks
|
||||
);
|
||||
|
||||
final mediumThumbnailQueue = TaskQueue<String>(
|
||||
maxConcurrentTasks: 5,
|
||||
taskTimeout: const Duration(minutes: 1),
|
||||
maxQueueSize: 200, // Limit the queue to 50 pending tasks
|
||||
);
|
||||
|
||||
class LocalThumbnailProvider extends ImageProvider<LocalThumbnailProviderKey> {
|
||||
final LocalThumbnailProviderKey key;
|
||||
final int maxRetries;
|
||||
final Duration retryDelay;
|
||||
|
||||
LocalThumbnailProvider(
|
||||
this.key, {
|
||||
this.maxRetries = 300,
|
||||
this.retryDelay = const Duration(milliseconds: 5),
|
||||
});
|
||||
|
||||
@override
|
||||
Future<LocalThumbnailProviderKey> obtainKey(
|
||||
ImageConfiguration configuration,
|
||||
) async {
|
||||
return SynchronousFuture<LocalThumbnailProviderKey>(key);
|
||||
}
|
||||
|
||||
static cancelRequest(LocalThumbnailProviderKey key) {
|
||||
thumbnailQueue.removeTask('${key.asset.id}-small');
|
||||
mediumThumbnailQueue.removeTask('${key.asset.id}-medium');
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
LocalThumbnailProviderKey key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||
return MultiImageStreamCompleter(
|
||||
codec: _codec(key, decode, chunkEvents),
|
||||
scale: 1.0,
|
||||
chunkEvents: chunkEvents.stream,
|
||||
informationCollector: () sync* {
|
||||
yield ErrorDescription('id: ${key.asset.id} name: ${key.asset.title}');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Stream<ui.Codec> _codec(
|
||||
LocalThumbnailProviderKey key,
|
||||
ImageDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent> chunkEvents,
|
||||
) async* {
|
||||
// First try to get from cache
|
||||
Uint8List? normalThumbBytes =
|
||||
enteImageCache.getThumbByID(key.asset.id, key.height);
|
||||
if (normalThumbBytes != null) {
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(normalThumbBytes);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
chunkEvents.close().ignore();
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to load small thumbnail with retry logic
|
||||
final Uint8List? thumbBytes = await _loadWithRetry(
|
||||
key: key,
|
||||
size: ThumbnailSize(key.smallThumbWidth, key.smallThumbHeight),
|
||||
quality: 75,
|
||||
cacheKey: '${key.asset.id}-small',
|
||||
queue: thumbnailQueue,
|
||||
cacheWidth: key.smallThumbWidth,
|
||||
);
|
||||
|
||||
if (thumbBytes != null) {
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
} else {
|
||||
debugPrint("$runtimeType smallThumb ${key.asset.title} failed");
|
||||
}
|
||||
|
||||
// Try to load normal thumbnail with retry logic if not already in cache
|
||||
if (normalThumbBytes == null) {
|
||||
normalThumbBytes = await _loadWithRetry(
|
||||
key: key,
|
||||
size: ThumbnailSize(key.width, key.height),
|
||||
quality: 50,
|
||||
cacheKey: '${key.asset.id}-medium',
|
||||
queue: mediumThumbnailQueue,
|
||||
cacheWidth: key.height,
|
||||
);
|
||||
|
||||
if (normalThumbBytes == null) {
|
||||
throw StateError("$runtimeType biThumb ${key.asset.title} failed");
|
||||
}
|
||||
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(normalThumbBytes);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
}
|
||||
|
||||
chunkEvents.close().ignore();
|
||||
}
|
||||
|
||||
Future<Uint8List?> _loadWithRetry({
|
||||
required LocalThumbnailProviderKey key,
|
||||
required ThumbnailSize size,
|
||||
required int quality,
|
||||
required String cacheKey,
|
||||
required TaskQueue<String> queue,
|
||||
required int cacheWidth,
|
||||
}) async {
|
||||
int attempt = 0;
|
||||
Uint8List? result;
|
||||
|
||||
while (attempt <= maxRetries) {
|
||||
try {
|
||||
// Check cache first on retry attempts
|
||||
if (attempt > 0) {
|
||||
result = enteImageCache.getThumbByID(key.asset.id, cacheWidth);
|
||||
if (result != null) return result;
|
||||
}
|
||||
|
||||
final Completer<Uint8List?> future = Completer();
|
||||
await queue.addTask(cacheKey, () async {
|
||||
final bytes =
|
||||
await key.asset.thumbnailDataWithSize(size, quality: quality);
|
||||
enteImageCache.putThumbByID(key.asset.id, bytes, cacheWidth);
|
||||
future.complete(bytes);
|
||||
});
|
||||
result = await future.future;
|
||||
return result;
|
||||
} catch (e) {
|
||||
// Only retry on specific exceptions
|
||||
if (e is! TaskQueueOverflowException &&
|
||||
e is! TaskQueueTimeoutException &&
|
||||
e is! TaskQueueCancelledException) {
|
||||
rethrow;
|
||||
}
|
||||
|
||||
attempt++;
|
||||
if (attempt <= maxRetries) {
|
||||
await Future.delayed(retryDelay * attempt); // Exponential backoff
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class LocalThumbnailProviderKey extends Equatable {
|
||||
final AssetEntity asset;
|
||||
final int height;
|
||||
final int width;
|
||||
final int smallThumbHeight;
|
||||
final int smallThumbWidth;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
asset.id,
|
||||
asset.modifiedDateSecond ?? 0,
|
||||
height,
|
||||
width,
|
||||
smallThumbHeight,
|
||||
smallThumbWidth,
|
||||
];
|
||||
|
||||
const LocalThumbnailProviderKey({
|
||||
required this.asset,
|
||||
this.height = 256,
|
||||
this.width = 256,
|
||||
this.smallThumbWidth = 32,
|
||||
this.smallThumbHeight = 32,
|
||||
});
|
||||
}
|
||||
@@ -372,7 +372,7 @@
|
||||
"deleteFromBoth": "الحذف من كليهما",
|
||||
"newAlbum": "ألبوم جديد",
|
||||
"albums": "الألبومات",
|
||||
"memoryCount": "{count, plural, =0 {لا توجد ذكريات} one {ذكرى واحدة} two {ذكريتان} few {{formattedCount} ذكريات} many {{formattedCount} ذكرى} other {{formattedCount} ذكرى}}",
|
||||
"memoryCount": "{count, plural, =0 {لا توجد ذكريات} one {ذكرى واحدة} two {ذكريتان} other {{formattedCount} ذكرى}}",
|
||||
"@memoryCount": {
|
||||
"description": "The text to display the number of memories",
|
||||
"type": "text",
|
||||
@@ -460,7 +460,7 @@
|
||||
"skip": "تخط",
|
||||
"updatingFolderSelection": "جارٍ تحديث تحديد المجلد...",
|
||||
"itemCount": "{count, plural, one {{count} عُنْصُر} other {{count} عَنَاصِر}}",
|
||||
"deleteItemCount": "{count, plural, =1 {حذف عنصر واحد} two {حذف عنصرين} few {حذف {count} عناصر} many {حذف {count} عنصرًا} other {حذف {count} عنصرًا}}",
|
||||
"deleteItemCount": "{count, plural, =1 {حذف عنصر واحد} two {حذف عنصرين} other {حذف {count} عنصرًا}}",
|
||||
"duplicateItemsGroup": "{count} ملفات، {formattedSize} لكل منها",
|
||||
"@duplicateItemsGroup": {
|
||||
"description": "Display the number of duplicate files and their size",
|
||||
@@ -477,7 +477,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "عرض الذكريات",
|
||||
"yearsAgo": "{count, plural, one {قبل سنة} two {قبل سنتين} few {قبل {count} سنوات} many {قبل {count} سنة} other {قبل {count} سنة}}",
|
||||
"yearsAgo": "{count, plural, one {قبل سنة} two {قبل سنتين} other {قبل {count} سنة}}",
|
||||
"backupSettings": "إعدادات النسخ الاحتياطي",
|
||||
"backupStatus": "حالة النسخ الاحتياطي",
|
||||
"backupStatusDescription": "ستظهر العناصر التي تم نسخها احتياطيًا هنا",
|
||||
@@ -543,7 +543,7 @@
|
||||
},
|
||||
"remindToEmptyEnteTrash": "تذكر أيضًا إفراغ \"سلة المهملات\" لاستعادة المساحة المحررة.",
|
||||
"sparkleSuccess": "✨ نجاح",
|
||||
"duplicateFileCountWithStorageSaved": "لقد قمت بتنظيف {count, plural, one {ملف مكرر واحد} two {ملفين مكررين} few {{count} ملفات مكررة} many {{count} ملفًا مكررًا} other {{count} ملفًا مكررًا}}، مما وفر {storageSaved}!",
|
||||
"duplicateFileCountWithStorageSaved": "لقد قمت بتنظيف {count, plural, one {ملف مكرر واحد} two {ملفين مكررين} other {{count} ملفًا مكررًا}}، مما وفر {storageSaved}!",
|
||||
"@duplicateFileCountWithStorageSaved": {
|
||||
"description": "The text to display when the user has successfully cleaned up duplicate files",
|
||||
"type": "text",
|
||||
@@ -794,11 +794,11 @@
|
||||
"share": "مشاركة",
|
||||
"unhideToAlbum": "إظهار في الألبوم",
|
||||
"restoreToAlbum": "استعادة إلى الألبوم",
|
||||
"moveItem": "{count, plural, =1 {نقل عنصر} two {نقل عنصرين} few {نقل {count} عناصر} many {نقل {count} عنصرًا} other {نقل {count} عنصرًا}}",
|
||||
"moveItem": "{count, plural, =1 {نقل عنصر} two {نقل عنصرين} other {نقل {count} عنصرًا}}",
|
||||
"@moveItem": {
|
||||
"description": "Page title while moving one or more items to an album"
|
||||
},
|
||||
"addItem": "{count, plural, =1 {إضافة عنصر} two {إضافة عنصرين} few {إضافة {count} عناصر} many {إضافة {count} عنصرًا} other {إضافة {count} عنصرًا}}",
|
||||
"addItem": "{count, plural, =1 {إضافة عنصر} two {إضافة عنصرين} other {إضافة {count} عنصرًا}}",
|
||||
"@addItem": {
|
||||
"description": "Page title while adding one or more items to album"
|
||||
},
|
||||
@@ -826,7 +826,7 @@
|
||||
"referFriendsAnd2xYourPlan": "أحِل الأصدقاء وضاعف خطتك مرتين",
|
||||
"shareAlbumHint": "افتح ألبومًا وانقر على زر المشاركة في الزاوية اليمنى العليا للمشاركة.",
|
||||
"itemsShowTheNumberOfDaysRemainingBeforePermanentDeletion": "تعرض العناصر عدد الأيام المتبقية قبل الحذف الدائم.",
|
||||
"trashDaysLeft": "{count, plural, =0 {قريبًا} =1 {يوم واحد} two {يومان} few {{count} أيام} many {{count} يومًا} other {{count} يومًا}}",
|
||||
"trashDaysLeft": "{count, plural, =0 {قريبًا} =1 {يوم واحد} two {يومان} other {{count} يومًا}}",
|
||||
"@trashDaysLeft": {
|
||||
"description": "Text to indicate number of days remaining before permanent deletion",
|
||||
"placeholders": {
|
||||
@@ -899,8 +899,8 @@
|
||||
"authToViewYourMemories": "يرجى المصادقة لعرض ذكرياتك.",
|
||||
"unlock": "فتح",
|
||||
"freeUpSpace": "تحرير المساحة",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {يمكن حذفه من الجهاز لتحرير {formattedSize}} two {يمكن حذفهما من الجهاز لتحرير {formattedSize}} few {يمكن حذفها من الجهاز لتحرير {formattedSize}} many {يمكن حذفها من الجهاز لتحرير {formattedSize}} other {يمكن حذفها من الجهاز لتحرير {formattedSize}}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {ملف واحد} two {ملفان} few {{formattedNumber} ملفات} many {{formattedNumber} ملفًا} other {{formattedNumber} ملفًا}} في هذا الألبوم تم نسخه احتياطيًا بأمان",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {يمكن حذفه من الجهاز لتحرير {formattedSize}} two {يمكن حذفهما من الجهاز لتحرير {formattedSize}} other {يمكن حذفها من الجهاز لتحرير {formattedSize}}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {ملف واحد} two {ملفان} other {{formattedNumber} ملفًا}} في هذا الألبوم تم نسخه احتياطيًا بأمان",
|
||||
"@filesBackedUpInAlbum": {
|
||||
"description": "Text to tell user how many files have been backed up in the album",
|
||||
"placeholders": {
|
||||
@@ -915,7 +915,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"filesBackedUpFromDevice": "{count, plural, one {ملف واحد} two {ملفان} few {{formattedNumber} ملفات} many {{formattedNumber} ملفًا} other {{formattedNumber} ملفًا}} على هذا الجهاز تم نسخه احتياطيًا بأمان",
|
||||
"filesBackedUpFromDevice": "{count, plural, one {ملف واحد} two {ملفان} other {{formattedNumber} ملفًا}} على هذا الجهاز تم نسخه احتياطيًا بأمان",
|
||||
"@filesBackedUpFromDevice": {
|
||||
"description": "Text to tell user how many files have been backed up from this device",
|
||||
"placeholders": {
|
||||
@@ -1217,7 +1217,7 @@
|
||||
"searchHint4": "الموقع",
|
||||
"searchHint5": "قريبًا: الوجوه والبحث السحري ✨",
|
||||
"addYourPhotosNow": "أضف صورك الآن",
|
||||
"searchResultCount": "{count, plural, one{{count} النتائج التي تم العثور عليها} other{{count} النتائج التي تم العثور عليها}}",
|
||||
"searchResultCount": "{count, plural, other{{count} النتائج التي تم العثور عليها}}",
|
||||
"@searchResultCount": {
|
||||
"description": "Text to tell user how many results were found for their search query",
|
||||
"placeholders": {
|
||||
@@ -1269,8 +1269,8 @@
|
||||
"description": "Subtitle to indicate that the user can find people quickly by name"
|
||||
},
|
||||
"findPeopleByName": "البحث عن الأشخاص بسرعة بالاسم",
|
||||
"addViewers": "{count, plural, =0 {إضافة مشاهد} =1 {إضافة مشاهد} two {إضافة مشاهدين} few {إضافة {count} مشاهدين} many {إضافة {count} مشاهدًا} other {إضافة {count} مشاهدًا}}",
|
||||
"addCollaborators": "{count, plural, =0 {إضافة متعاون} =1 {إضافة متعاون} two {إضافة متعاونين} few {إضافة {count} متعاونين} many {إضافة {count} متعاونًا} other {إضافة {count} متعاونًا}}",
|
||||
"addViewers": "{count, plural, =0 {إضافة مشاهد} =1 {إضافة مشاهد} two {إضافة مشاهدين} other {إضافة {count} مشاهدًا}}",
|
||||
"addCollaborators": "{count, plural, =0 {إضافة متعاون} =1 {إضافة متعاون} two {إضافة متعاونين} other {إضافة {count} متعاونًا}}",
|
||||
"longPressAnEmailToVerifyEndToEndEncryption": "اضغط مطولاً على بريد إلكتروني للتحقق من التشفير من طرف إلى طرف.",
|
||||
"developerSettingsWarning": "هل أنت متأكد من رغبتك في تعديل إعدادات المطور؟",
|
||||
"developerSettings": "إعدادات المطور",
|
||||
@@ -1403,7 +1403,7 @@
|
||||
"enableMachineLearningBanner": "قم بتمكين تعلم الآلة للبحث السحري والتعرف على الوجوه.",
|
||||
"searchDiscoverEmptySection": "سيتم عرض الصور هنا بمجرد اكتمال المعالجة والمزامنة.",
|
||||
"searchPersonsEmptySection": "سيتم عرض الأشخاص هنا بمجرد اكتمال المعالجة والمزامنة.",
|
||||
"viewersSuccessfullyAdded": "{count, plural, =0 {تمت إضافة 0 مشاهدين} =1 {تمت إضافة مشاهد واحد} two {تمت إضافة مشاهدين} few {تمت إضافة {count} مشاهدين} many {تمت إضافة {count} مشاهدًا} other {تمت إضافة {count} مشاهدًا}}",
|
||||
"viewersSuccessfullyAdded": "{count, plural, =0 {تمت إضافة 0 مشاهدين} =1 {تمت إضافة مشاهد واحد} two {تمت إضافة مشاهدين} other {تمت إضافة {count} مشاهدًا}}",
|
||||
"@viewersSuccessfullyAdded": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1413,7 +1413,7 @@
|
||||
},
|
||||
"description": "Number of viewers that were successfully added to an album."
|
||||
},
|
||||
"collaboratorsSuccessfullyAdded": "{count, plural, =0 {تمت إضافة 0 متعاونين} =1 {تمت إضافة متعاون واحد} two {تمت إضافة متعاونين} few {تمت إضافة {count} متعاونين} many {تمت إضافة {count} متعاونًا} other {تمت إضافة {count} متعاونًا}}",
|
||||
"collaboratorsSuccessfullyAdded": "{count, plural, =0 {تمت إضافة 0 متعاونين} =1 {تمت إضافة متعاون واحد} two {تمت إضافة متعاونين} other {تمت إضافة {count} متعاونًا}}",
|
||||
"@collaboratorsSuccessfullyAdded": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1488,7 +1488,7 @@
|
||||
},
|
||||
"currentlyRunning": "قيد التشغيل حاليًا",
|
||||
"ignored": "تم التجاهل",
|
||||
"photosCount": "{count, plural, =0 {لا توجد صور} =1 {صورة واحدة} two {صورتان} few {{count} صور} many {{count} صورة} other {{count} صورة}}",
|
||||
"photosCount": "{count, plural, =0 {لا توجد صور} =1 {صورة واحدة} two {صورتان} other {{count} صورة}}",
|
||||
"@photosCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1686,7 +1686,7 @@
|
||||
"moveSelectedPhotosToOneDate": "نقل الصور المحددة إلى تاريخ واحد",
|
||||
"shiftDatesAndTime": "تغيير التواريخ والوقت",
|
||||
"photosKeepRelativeTimeDifference": "تحتفظ الصور بالفرق الزمني النسبي",
|
||||
"photocountPhotos": "{count, plural, =0 {لا توجد صور} =1 {صورة واحدة} two {صورتان} few {{count} صور} many {{count} صورة} other {{count} صورة}}",
|
||||
"photocountPhotos": "{count, plural, =0 {لا توجد صور} =1 {صورة واحدة} two {صورتان} other {{count} صورة}}",
|
||||
"@photocountPhotos": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1700,7 +1700,7 @@
|
||||
"selectedItemsWillBeRemovedFromThisPerson": "سيتم إزالة العناصر المحددة من هذا الشخص، ولكن لن يتم حذفها من مكتبتك.",
|
||||
"throughTheYears": "{dateFormat} عبر السنين",
|
||||
"thisWeekThroughTheYears": "هذا الأسبوع عبر السنين",
|
||||
"thisWeekXYearsAgo": "{count, plural, =1 {هذا الأسبوع، قبل سنة} two {هذا الأسبوع، قبل سنتين} few {هذا الأسبوع، قبل {count} سنوات} many {هذا الأسبوع، قبل {count} سنة} other {هذا الأسبوع، قبل {count} سنة}}",
|
||||
"thisWeekXYearsAgo": "{count, plural, =1 {هذا الأسبوع، قبل سنة} two {هذا الأسبوع، قبل سنتين} other {هذا الأسبوع، قبل {count} سنة}}",
|
||||
"youAndThem": "أنت و {name}",
|
||||
"admiringThem": "الإعجاب بـ {name}",
|
||||
"embracingThem": "معانقة {name}",
|
||||
@@ -1821,4 +1821,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"deleteFromBoth": "Odstranit z obou",
|
||||
"newAlbum": "Nové album",
|
||||
"albums": "Alba",
|
||||
"memoryCount": "{count, plural, =0{žádné vzpomínky} one{{formattedCount} vzpomínka} few{{formattedCount} vzpomínky} other{{formattedCount} vzpomínek}}",
|
||||
"memoryCount": "{count, plural, =0{žádné vzpomínky} one{{formattedCount} vzpomínka} other{{formattedCount} vzpomínek}}",
|
||||
"@memoryCount": {
|
||||
"description": "The text to display the number of memories",
|
||||
"type": "text",
|
||||
@@ -459,8 +459,8 @@
|
||||
"selectAll": "Vybrat vše",
|
||||
"skip": "Přeskočit",
|
||||
"updatingFolderSelection": "Aktualizuji výběr složek...",
|
||||
"itemCount": "{count, plural, one{{count} položka} few{{count} položky} other{{count} položek}}",
|
||||
"deleteItemCount": "{count, plural, =1 {Smazat {count} položku} few{Smazat {count} položky} other {Smazat {count} položek}}",
|
||||
"itemCount": "{count, plural, one{{count} položka} other{{count} položek}}",
|
||||
"deleteItemCount": "{count, plural, =1 {Smazat {count} položku} other {Smazat {count} položek}}",
|
||||
"duplicateItemsGroup": "{count} souborů, {formattedSize} každý",
|
||||
"@duplicateItemsGroup": {
|
||||
"description": "Display the number of duplicate files and their size",
|
||||
@@ -543,7 +543,7 @@
|
||||
},
|
||||
"remindToEmptyEnteTrash": "Vyprázdněte také \"Koš\", abyste získali uvolněné místo",
|
||||
"sparkleSuccess": "✨ Úspěch",
|
||||
"duplicateFileCountWithStorageSaved": "Vyčistili jste {count, plural, one{{count} duplicitní soubor} few{{count} duplicitní soubory} other{{count} duplicitních souborů}}, a ušetřili jste {storageSaved}!",
|
||||
"duplicateFileCountWithStorageSaved": "Vyčistili jste {count, plural, one{{count} duplicitní soubor} other{{count} duplicitních souborů}}, a ušetřili jste {storageSaved}!",
|
||||
"@duplicateFileCountWithStorageSaved": {
|
||||
"description": "The text to display when the user has successfully cleaned up duplicate files",
|
||||
"type": "text",
|
||||
@@ -826,7 +826,7 @@
|
||||
"referFriendsAnd2xYourPlan": "Doporučte přátele a zdvojnásobte svůj tarif",
|
||||
"shareAlbumHint": "Otevřete album a klepněte na tlačítko sdílení v pravém horním rohu, abyste jej sdíleli.",
|
||||
"itemsShowTheNumberOfDaysRemainingBeforePermanentDeletion": "Položky zobrazují počet dní zbývajících do trvalého smazání",
|
||||
"trashDaysLeft": "{count, plural, =0{Brzy} =1{1 den} few{{count} dny} other{{count} dní}}",
|
||||
"trashDaysLeft": "{count, plural, =0{Brzy} =1{1 den} other{{count} dní}}",
|
||||
"@trashDaysLeft": {
|
||||
"description": "Text to indicate number of days remaining before permanent deletion",
|
||||
"placeholders": {
|
||||
@@ -900,7 +900,7 @@
|
||||
"unlock": "Odemknout",
|
||||
"freeUpSpace": "Uvolnit místo",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {Lze jej odstranit ze zařízení, aby se uvolnilo {formattedSize} místa} other {Lze je odstranit ze zařízení, aby se uvolnilo {formattedSize} místa}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {1 soubor v tomto albu byl bezpečně zálohován} few {{formattedNumber} soubory v tomto albu byly bezpečně zálohovány} other {{formattedNumber} souborů v tomto albu bylo bezpečně zálohováno}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {1 soubor v tomto albu byl bezpečně zálohován} other {{formattedNumber} souborů v tomto albu bylo bezpečně zálohováno}}",
|
||||
"@filesBackedUpInAlbum": {
|
||||
"description": "Text to tell user how many files have been backed up in the album",
|
||||
"placeholders": {
|
||||
@@ -915,7 +915,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"filesBackedUpFromDevice": "{count, plural, one {1 soubor na tomto zařízení byl bezpečně zálohován} few {{formattedNumber} soubory na tomto zařízení byly bezpečně zálohovány} other {{formattedNumber} souborů na tomto zařízení bylo bezpečně zálohováno}}",
|
||||
"filesBackedUpFromDevice": "{count, plural, one {1 soubor na tomto zařízení byl bezpečně zálohován} other {{formattedNumber} souborů na tomto zařízení bylo bezpečně zálohováno}}",
|
||||
"@filesBackedUpFromDevice": {
|
||||
"description": "Text to tell user how many files have been backed up from this device",
|
||||
"placeholders": {
|
||||
@@ -1217,7 +1217,7 @@
|
||||
"searchHint4": "Poloha",
|
||||
"searchHint5": "Již brzy: Kouzelné vyhledávání tváří ✨",
|
||||
"addYourPhotosNow": "Přidejte své fotografie nyní",
|
||||
"searchResultCount": "{count, plural, one{Nalezen {count} výsledek} few{Nalezeny {count} výsledky} other{Nalezeno {count} výsledků}}",
|
||||
"searchResultCount": "{count, plural, one{Nalezen {count} výsledek} other{Nalezeno {count} výsledků}}",
|
||||
"@searchResultCount": {
|
||||
"description": "Text to tell user how many results were found for their search query",
|
||||
"placeholders": {
|
||||
@@ -1403,7 +1403,7 @@
|
||||
"enableMachineLearningBanner": "Povolte strojové učení pro magické vyhledávání a rozpoznávání obličejů",
|
||||
"searchDiscoverEmptySection": "Obrázky se zde zobrazí po dokončení zpracování a synchronizace",
|
||||
"searchPersonsEmptySection": "Lidé se zde zobrazí po dokončení zpracování a synchronizace",
|
||||
"viewersSuccessfullyAdded": "{count, plural, =0 {Přidáno 0 pozorovatelů} =1 {Přidán 1 pozorovatel} few {Přidáni {count} pozorovatelé} other {Přidáno {count} pozorovatelů}}",
|
||||
"viewersSuccessfullyAdded": "{count, plural, =0 {Přidáno 0 pozorovatelů} =1 {Přidán 1 pozorovatel} other {Přidáno {count} pozorovatelů}}",
|
||||
"@viewersSuccessfullyAdded": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1413,7 +1413,7 @@
|
||||
},
|
||||
"description": "Number of viewers that were successfully added to an album."
|
||||
},
|
||||
"collaboratorsSuccessfullyAdded": "{count, plural, =0 {Přidáno 0 spolupracovníků} =1 {Přidán 1 spolupracovník} few {Přidáni {count} spolupracovníci} other {Přidáno {count} spolupracovníků}}",
|
||||
"collaboratorsSuccessfullyAdded": "{count, plural, =0 {Přidáno 0 spolupracovníků} =1 {Přidán 1 spolupracovník} other {Přidáno {count} spolupracovníků}}",
|
||||
"@collaboratorsSuccessfullyAdded": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1488,7 +1488,7 @@
|
||||
},
|
||||
"currentlyRunning": "aktuálně běží",
|
||||
"ignored": "ignorováno",
|
||||
"photosCount": "{count, plural, =0 {0 fotografií} =1 {1 fotografie} few {{count} fotografie} other {{count} fotografií}}",
|
||||
"photosCount": "{count, plural, =0 {0 fotografií} =1 {1 fotografie} other {{count} fotografií}}",
|
||||
"@photosCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1686,7 +1686,7 @@
|
||||
"moveSelectedPhotosToOneDate": "Přesunout vybrané fotografie do jednoho data",
|
||||
"shiftDatesAndTime": "Posunout datum a čas",
|
||||
"photosKeepRelativeTimeDifference": "Fotografie zachovávají relativní časový rozdíl",
|
||||
"photocountPhotos": "{count, plural, =0 {Žádné fotografie} =1 {1 fotografie} few {{count} fotografie} other {{count} fotografií}}",
|
||||
"photocountPhotos": "{count, plural, =0 {Žádné fotografie} =1 {1 fotografie} other {{count} fotografií}}",
|
||||
"@photocountPhotos": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1700,7 +1700,7 @@
|
||||
"selectedItemsWillBeRemovedFromThisPerson": "Vybrané položky budou z této osoby odebrány, ale nebudou smazány z vaší knihovny.",
|
||||
"throughTheYears": "{dateFormat} v průběhu let",
|
||||
"thisWeekThroughTheYears": "Tento týden v průběhu let",
|
||||
"thisWeekXYearsAgo": "{count, plural, =1 {Tento týden, {count} rok nazpět} few {Tento týden, {count} roky nazpět} other {Tento týden, {count} let nazpět}}",
|
||||
"thisWeekXYearsAgo": "{count, plural, =1 {Tento týden, {count} rok nazpět} other {Tento týden, {count} let nazpět}}",
|
||||
"youAndThem": "Vy a {name}",
|
||||
"admiringThem": "Obdiv k {name}",
|
||||
"embracingThem": "Objímání {name}",
|
||||
@@ -1844,7 +1844,7 @@
|
||||
"findSimilarImages": "Najít podobné obrázky",
|
||||
"noSimilarImagesFound": "Nebyly nalezeny žádné podobné obrázky",
|
||||
"yourPhotosLookUnique": "Vaše fotografie vypadají jedinečně",
|
||||
"similarGroupsFound": "{count, plural, =1{{count} skupina nalezena} few{{count} skupiny nalezeny} other{{count} skupin nalezeno}}",
|
||||
"similarGroupsFound": "{count, plural, =1{{count} skupina nalezena} other{{count} skupin nalezeno}}",
|
||||
"@similarGroupsFound": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1921,4 +1921,4 @@
|
||||
"similar": "Podobné",
|
||||
"identical": "Identické",
|
||||
"nothingHereTryAnotherFilter": "Tady nic není, zkuste jiný filtr! 👀"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,16 +207,6 @@
|
||||
"after1Month": "Efter 1 måned",
|
||||
"after1Year": "Efter 1 år",
|
||||
"manageParticipants": "Administrer",
|
||||
"albumParticipantsCount": "{count, plural, =0 {Ingen Deltagere} =1 {1 Deltager} other {{count} Deltagere}}",
|
||||
"@albumParticipantsCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int",
|
||||
"example": "5"
|
||||
}
|
||||
},
|
||||
"description": "Number of participants in an album, including the album owner."
|
||||
},
|
||||
"collabLinkSectionDescription": "Opret et link, så folk kan tilføje og se fotos i dit delte album uden at behøve en Ente-app eller konto. Fantastisk til at indsamle event fotos.",
|
||||
"collectPhotos": "Indsaml billeder",
|
||||
"collaborativeLink": "Kollaborativt link",
|
||||
|
||||
@@ -1819,7 +1819,7 @@
|
||||
"font": "Fuente",
|
||||
"background": "Fondo",
|
||||
"align": "Alinear",
|
||||
"addedToAlbums": "{count, plural, one {}=1{Añadido con éxito a 1 álbum} other{Añadido con éxito a {count} álbumes}}",
|
||||
"addedToAlbums": "{count, plural, =1{Añadido con éxito a 1 álbum} other{Añadido con éxito a {count} álbumes}}",
|
||||
"@addedToAlbums": {
|
||||
"description": "Message shown when items are added to albums",
|
||||
"placeholders": {
|
||||
@@ -1846,7 +1846,7 @@
|
||||
"findSimilarImages": "Buscar imágenes similares",
|
||||
"noSimilarImagesFound": "No se encontraron imágenes similares",
|
||||
"yourPhotosLookUnique": "Tus fotos se ven únicas",
|
||||
"similarGroupsFound": "{count, plural, one {}=1{{count} grupo encontrado} other{{count} grupos encontrados}}",
|
||||
"similarGroupsFound": "{count, plural, =1{{count} grupo encontrado} other{{count} grupos encontrados}}",
|
||||
"@similarGroupsFound": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1934,4 +1934,4 @@
|
||||
"related": "Relacionado",
|
||||
"hoorayyyy": "¡Hurraaaa!",
|
||||
"nothingToTidyUpHere": "Nada que limpiar aquí"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -899,7 +899,7 @@
|
||||
"authToViewYourMemories": "Authentifiez-vous pour voir vos souvenirs",
|
||||
"unlock": "Déverrouiller",
|
||||
"freeUpSpace": "Libérer de l'espace",
|
||||
"freeUpSpaceSaving": "{count, plural, one {}=1 {Il peut être supprimé de l'appareil pour libérer {formattedSize}} other {Ils peuvent être supprimés de l'appareil pour libérer {formattedSize}}}",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {Il peut être supprimé de l'appareil pour libérer {formattedSize}} other {Ils peuvent être supprimés de l'appareil pour libérer {formattedSize}}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {1 fichier dans cet album a été sauvegardé en toute sécurité} other {{formattedNumber} fichiers dans cet album ont été sauvegardés en toute sécurité}}",
|
||||
"@filesBackedUpInAlbum": {
|
||||
"description": "Text to tell user how many files have been backed up in the album",
|
||||
@@ -933,7 +933,7 @@
|
||||
"@freeUpSpaceSaving": {
|
||||
"description": "Text to tell user how much space they can free up by deleting items from the device"
|
||||
},
|
||||
"freeUpAccessPostDelete": "Vous pouvez toujours {count, plural, one {}=1 {l'} other {les}} accéder sur Ente tant que vous avez un abonnement actif",
|
||||
"freeUpAccessPostDelete": "Vous pouvez toujours {count, plural, =1 {l'} other {les}} accéder sur Ente tant que vous avez un abonnement actif",
|
||||
"@freeUpAccessPostDelete": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1269,8 +1269,8 @@
|
||||
"description": "Subtitle to indicate that the user can find people quickly by name"
|
||||
},
|
||||
"findPeopleByName": "Trouver des personnes rapidement par leur nom",
|
||||
"addViewers": "{count, plural, one {}=0 {Ajouter un spectateur} =1 {Ajouter une spectateur} other {Ajouter des spectateurs}}",
|
||||
"addCollaborators": "{count, plural, one {}=0 {Ajouter un collaborateur} =1 {Ajouter un collaborateur} other {Ajouter des collaborateurs}}",
|
||||
"addViewers": "{count, plural, =0 {Ajouter un spectateur} =1 {Ajouter une spectateur} other {Ajouter des spectateurs}}",
|
||||
"addCollaborators": "{count, plural, =0 {Ajouter un collaborateur} =1 {Ajouter un collaborateur} other {Ajouter des collaborateurs}}",
|
||||
"longPressAnEmailToVerifyEndToEndEncryption": "Appuyez longuement sur un email pour vérifier le chiffrement de bout en bout.",
|
||||
"developerSettingsWarning": "Êtes-vous sûr de vouloir modifier les paramètres du développeur ?",
|
||||
"developerSettings": "Paramètres du développeur",
|
||||
@@ -1403,7 +1403,7 @@
|
||||
"enableMachineLearningBanner": "Activer l'apprentissage automatique pour la reconnaissance des visages et la recherche magique",
|
||||
"searchDiscoverEmptySection": "Les images seront affichées ici une fois le traitement terminé",
|
||||
"searchPersonsEmptySection": "Les personnes seront affichées ici une fois le traitement terminé",
|
||||
"viewersSuccessfullyAdded": "{count, plural, one {}=0 {0 spectateur ajouté} =1 {Un spectateur ajouté} other {{count} spectateurs ajoutés}}",
|
||||
"viewersSuccessfullyAdded": "{count, plural, =0 {0 spectateur ajouté} =1 {Un spectateur ajouté} other {{count} spectateurs ajoutés}}",
|
||||
"@viewersSuccessfullyAdded": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1819,7 +1819,7 @@
|
||||
"font": "Police",
|
||||
"background": "Arrière-plan",
|
||||
"align": "Aligner",
|
||||
"addedToAlbums": "{count, plural, one {}=1{Ajouté avec succès à 1 album} other{Ajouté avec succès à {count} albums}}",
|
||||
"addedToAlbums": "{count, plural, =1{Ajouté avec succès à 1 album} other{Ajouté avec succès à {count} albums}}",
|
||||
"@addedToAlbums": {
|
||||
"description": "Message shown when items are added to albums",
|
||||
"placeholders": {
|
||||
@@ -1926,4 +1926,4 @@
|
||||
"related": "Liés",
|
||||
"hoorayyyy": "Houraaa !",
|
||||
"nothingToTidyUpHere": "Rien à nettoyer ici"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,8 +389,8 @@
|
||||
"selectAll": "בחר הכל",
|
||||
"skip": "דלג",
|
||||
"updatingFolderSelection": "מעדכן את בחירת התיקיות...",
|
||||
"itemCount": "{count, plural, one{{count} פריט} two {{count} פריטים} many {{count} פריטים} other{{count} פריטים}}",
|
||||
"deleteItemCount": "{count, plural, =1 {מחק {count} פריט} two {מחק {count} פריטים} other {מחק {count} פריטים}}",
|
||||
"itemCount": "{count, plural, one{{count} פריט} other{{count} פריטים}}",
|
||||
"deleteItemCount": "{count, plural, =1 {מחק {count} פריט} other {מחק {count} פריטים}}",
|
||||
"duplicateItemsGroup": "{count} קבצים, כל אחד {formattedSize}",
|
||||
"@duplicateItemsGroup": {
|
||||
"description": "Display the number of duplicate files and their size",
|
||||
@@ -407,7 +407,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "הצג זכרונות",
|
||||
"yearsAgo": "{count, plural, one{לפני {count} שנה} two {לפני {count} שנים} many {לפני {count} שנים} other{לפני {count} שנים}}",
|
||||
"yearsAgo": "{count, plural, one{לפני {count} שנה} other{לפני {count} שנים}}",
|
||||
"backupSettings": "הגדרות גיבוי",
|
||||
"backupOverMobileData": "גבה על רשת סלולרית",
|
||||
"backupVideos": "גבה סרטונים",
|
||||
@@ -792,4 +792,4 @@
|
||||
"create": "צור",
|
||||
"viewAll": "הצג הכל",
|
||||
"hiding": "מחביא..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,7 +458,7 @@
|
||||
"selectAll": "Összes kijelölése",
|
||||
"skip": "Kihagyás",
|
||||
"updatingFolderSelection": "Mappakijelölés frissítése...",
|
||||
"itemCount": "{count, plural, one{{count} elem} other{{count} elem}}",
|
||||
"itemCount": "{count, plural, other{{count} elem}}",
|
||||
"deleteItemCount": "{count, plural, =1 {Elem {count} törlése} other {Elemek {count} törlése}}",
|
||||
"duplicateItemsGroup": "{count} fájl, {formattedSize} mindegyik",
|
||||
"@duplicateItemsGroup": {
|
||||
@@ -541,4 +541,4 @@
|
||||
}
|
||||
},
|
||||
"remindToEmptyEnteTrash": "Ürítsd ki a \"Kukát\" is, hogy visszaszerezd a felszabadult helyet."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"deleteFromBoth": "Hapus dari keduanya",
|
||||
"newAlbum": "Album baru",
|
||||
"albums": "Album",
|
||||
"memoryCount": "{count, plural, =0{tidak ada memori} one{{formattedCount} memori} other{{formattedCount} memori}}",
|
||||
"memoryCount": "{count, plural, =0{tidak ada memori} other{{formattedCount} memori}}",
|
||||
"@memoryCount": {
|
||||
"description": "The text to display the number of memories",
|
||||
"type": "text",
|
||||
@@ -1234,4 +1234,4 @@
|
||||
"left": "Kiri",
|
||||
"right": "Kanan",
|
||||
"whatsNew": "Hal yang baru"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -794,11 +794,11 @@
|
||||
"share": "Condividi",
|
||||
"unhideToAlbum": "Non nascondere l'album",
|
||||
"restoreToAlbum": "Ripristina l'album",
|
||||
"moveItem": "{count, plural, one {}=1 {Sposta elemento} other {Sposta elementi}}",
|
||||
"moveItem": "{count, plural, =1 {Sposta elemento} other {Sposta elementi}}",
|
||||
"@moveItem": {
|
||||
"description": "Page title while moving one or more items to an album"
|
||||
},
|
||||
"addItem": "{count, plural, one {}=1 {Aggiungi elemento} other {Aggiungi elementi}}",
|
||||
"addItem": "{count, plural, =1 {Aggiungi elemento} other {Aggiungi elementi}}",
|
||||
"@addItem": {
|
||||
"description": "Page title while adding one or more items to album"
|
||||
},
|
||||
@@ -899,7 +899,7 @@
|
||||
"authToViewYourMemories": "Autenticati per visualizzare le tue foto",
|
||||
"unlock": "Sblocca",
|
||||
"freeUpSpace": "Libera spazio",
|
||||
"freeUpSpaceSaving": "{count, plural, one {}=1 {Può essere cancellato per liberare {formattedSize}} other {Possono essere cancellati per liberare {formattedSize}}}",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {Può essere cancellato per liberare {formattedSize}} other {Possono essere cancellati per liberare {formattedSize}}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {1 file} other {{formattedNumber} file}} di quest'album sono stati salvati in modo sicuro",
|
||||
"@filesBackedUpInAlbum": {
|
||||
"description": "Text to tell user how many files have been backed up in the album",
|
||||
@@ -1260,8 +1260,8 @@
|
||||
"description": "Subtitle to indicate that the user can find people quickly by name"
|
||||
},
|
||||
"findPeopleByName": "Trova rapidamente le persone per nome",
|
||||
"addViewers": "{count, plural, one {}=0 {Aggiungi visualizzatore} =1 {Add viewer} other {Aggiungi visualizzatori}}",
|
||||
"addCollaborators": "{count, plural, one {}=0 {Aggiungi collaboratore} =1 {Aggiungi collaboratore} other {Aggiungi collaboratori}}",
|
||||
"addViewers": "{count, plural, =0 {Aggiungi visualizzatore} =1 {Add viewer} other {Aggiungi visualizzatori}}",
|
||||
"addCollaborators": "{count, plural, =0 {Aggiungi collaboratore} =1 {Aggiungi collaboratore} other {Aggiungi collaboratori}}",
|
||||
"longPressAnEmailToVerifyEndToEndEncryption": "Premi a lungo un'email per verificare la crittografia end to end.",
|
||||
"developerSettingsWarning": "Sei sicuro di voler modificare le Impostazioni sviluppatore?",
|
||||
"developerSettings": "Impostazioni sviluppatore",
|
||||
@@ -1394,7 +1394,7 @@
|
||||
"enableMachineLearningBanner": "Abilita l'apprendimento automatico per la ricerca magica e il riconoscimento facciale",
|
||||
"searchDiscoverEmptySection": "Le immagini saranno mostrate qui una volta che l'elaborazione e la sincronizzazione saranno completate",
|
||||
"searchPersonsEmptySection": "Le persone saranno mostrate qui una volta che l'elaborazione e la sincronizzazione saranno completate",
|
||||
"viewersSuccessfullyAdded": "{count, plural, one {}=0 {Added 0 visualizzatori} =1 {Added 1 visualizzatore} other {Added {count} visualizzatori}}",
|
||||
"viewersSuccessfullyAdded": "{count, plural, =0 {Added 0 visualizzatori} =1 {Added 1 visualizzatore} other {Added {count} visualizzatori}}",
|
||||
"@viewersSuccessfullyAdded": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1479,7 +1479,7 @@
|
||||
},
|
||||
"currentlyRunning": "attualmente in esecuzione",
|
||||
"ignored": "ignorato",
|
||||
"photosCount": "{count, plural, one {}=0 {0 foto} =1 {1 foto} other {{count} foto}}",
|
||||
"photosCount": "{count, plural, =0 {0 foto} =1 {1 foto} other {{count} foto}}",
|
||||
"@photosCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1677,7 +1677,7 @@
|
||||
"moveSelectedPhotosToOneDate": "Sposta foto selezionate in una data specifica",
|
||||
"shiftDatesAndTime": "Sposta date e orari",
|
||||
"photosKeepRelativeTimeDifference": "Le foto mantengono una differenza di tempo relativa",
|
||||
"photocountPhotos": "{count, plural, one {}=0 {Nessuna foto} =1 {1 foto} other {{count} foto}}",
|
||||
"photocountPhotos": "{count, plural, =0 {Nessuna foto} =1 {1 foto} other {{count} foto}}",
|
||||
"@photocountPhotos": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1691,7 +1691,7 @@
|
||||
"selectedItemsWillBeRemovedFromThisPerson": "Gli elementi selezionati verranno rimossi da questa persona, ma non eliminati dalla tua libreria.",
|
||||
"throughTheYears": "{dateFormat} negli anni",
|
||||
"thisWeekThroughTheYears": "Questa settimana negli anni",
|
||||
"thisWeekXYearsAgo": "{count, plural, one {}=1 {Questa settimana, {count} anno fa} other {Questa settimana, {count} anni fa}}",
|
||||
"thisWeekXYearsAgo": "{count, plural, =1 {Questa settimana, {count} anno fa} other {Questa settimana, {count} anni fa}}",
|
||||
"youAndThem": "Tu e {name}",
|
||||
"admiringThem": "Ammirando {name}",
|
||||
"embracingThem": "Abbracciando {name}",
|
||||
@@ -1746,4 +1746,4 @@
|
||||
"receiveRemindersOnBirthdays": "Ricevi promemoria quando è il compleanno di qualcuno. Toccare la notifica ti porterà alle foto della persona che compie gli anni.",
|
||||
"happyBirthday": "Buon compleanno! 🥳",
|
||||
"birthdays": "Compleanni"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,7 +461,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "思い出を表示",
|
||||
"yearsAgo": "{count, plural, one{{count} 年前} other{{count} 年前}}",
|
||||
"yearsAgo": "{count, plural, other{{count} 年前}}",
|
||||
"backupSettings": "バックアップ設定",
|
||||
"backupStatus": "バックアップの状態",
|
||||
"backupStatusDescription": "バックアップされたアイテムがここに表示されます",
|
||||
@@ -527,7 +527,7 @@
|
||||
},
|
||||
"remindToEmptyEnteTrash": "「ゴミ箱」も空にするとアカウントのストレージが解放されます",
|
||||
"sparkleSuccess": "成功✨",
|
||||
"duplicateFileCountWithStorageSaved": "お掃除しました {count, plural, one{{count} 個の重複ファイル} other{{count} 個の重複ファイル}}, ({storageSaved}が開放されます!)",
|
||||
"duplicateFileCountWithStorageSaved": "お掃除しました {count, plural, other{{count} 個の重複ファイル}}, ({storageSaved}が開放されます!)",
|
||||
"@duplicateFileCountWithStorageSaved": {
|
||||
"description": "The text to display when the user has successfully cleaned up duplicate files",
|
||||
"type": "text",
|
||||
@@ -1178,7 +1178,7 @@
|
||||
"searchHint4": "場所",
|
||||
"searchHint5": "近日公開: フェイスとマジック検索 ✨",
|
||||
"addYourPhotosNow": "写真を今すぐ追加する",
|
||||
"searchResultCount": "{count, plural, one{{count} 個の結果} other{{count} 個の結果}}",
|
||||
"searchResultCount": "{count, plural, other{{count} 個の結果}}",
|
||||
"@searchResultCount": {
|
||||
"description": "Text to tell user how many results were found for their search query",
|
||||
"placeholders": {
|
||||
@@ -1666,4 +1666,4 @@
|
||||
"onTheRoad": "再び道で",
|
||||
"food": "料理を楽しむ",
|
||||
"pets": "毛むくじゃらな仲間たち"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -794,11 +794,7 @@
|
||||
"share": "Bendrinti",
|
||||
"unhideToAlbum": "Rodyti į albumą",
|
||||
"restoreToAlbum": "Atkurti į albumą",
|
||||
"moveItem": "{count, plural, =1 {Perkelti elementą} other {Perkelti elementų}}",
|
||||
"@moveItem": {
|
||||
"description": "Page title while moving one or more items to an album"
|
||||
},
|
||||
"addItem": "{count, plural, =1 {Pridėti elementą} other {Pridėti elementų}}",
|
||||
"addItem": "{count, plural, =1 {Pridėti elementą} other {Pridėti elementų}}",
|
||||
"@addItem": {
|
||||
"description": "Page title while adding one or more items to album"
|
||||
},
|
||||
@@ -900,7 +896,7 @@
|
||||
"unlock": "Atrakinti",
|
||||
"freeUpSpace": "Atlaisvinti vietos",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {Jį galima ištrinti iš įrenginio, kad atlaisvintų {formattedSize}} other {Jų galima ištrinti iš įrenginio, kad atlaisvintų {formattedSize}}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {{formattedNumber} failas šiame albume saugiai sukurta atsarginė kopija} few {{formattedNumber} failai šiame albume saugiai sukurtos atsarginės kopijos} many {{formattedNumber} failo šiame albume saugiai sukurtos atsargines kopijos} other {{formattedNumber} failų šiame albume saugiai sukurta atsarginė kopija}}.",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {{formattedNumber} failas šiame albume saugiai sukurta atsarginė kopija} other {{formattedNumber} failų šiame albume saugiai sukurta atsarginė kopija}}.",
|
||||
"@filesBackedUpInAlbum": {
|
||||
"description": "Text to tell user how many files have been backed up in the album",
|
||||
"placeholders": {
|
||||
@@ -915,7 +911,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"filesBackedUpFromDevice": "{count, plural, one {{formattedNumber} failas šiame įrenginyje saugiai sukurta atsarginė kopija} few {{formattedNumber} failai šiame įrenginyje saugiai sukurtos atsarginės kopijos} many {{formattedNumber} failo šiame įrenginyje saugiai sukurtos atsargines kopijos} other {{formattedNumber} failų šiame įrenginyje saugiai sukurta atsarginių kopijų}}.",
|
||||
"filesBackedUpFromDevice": "{count, plural, one {{formattedNumber} failas šiame įrenginyje saugiai sukurta atsarginė kopija} other {{formattedNumber} failų šiame įrenginyje saugiai sukurta atsarginių kopijų}}.",
|
||||
"@filesBackedUpFromDevice": {
|
||||
"description": "Text to tell user how many files have been backed up from this device",
|
||||
"placeholders": {
|
||||
@@ -1403,7 +1399,7 @@
|
||||
"enableMachineLearningBanner": "Įjunkite mašininį mokymąsi magiškai paieškai ir veidų atpažinimui",
|
||||
"searchDiscoverEmptySection": "Vaizdai bus rodomi čia, kai bus užbaigtas apdorojimas ir sinchronizavimas.",
|
||||
"searchPersonsEmptySection": "Asmenys bus rodomi čia, kai bus užbaigtas apdorojimas ir sinchronizavimas.",
|
||||
"viewersSuccessfullyAdded": "{count, plural, one {Įtrauktas {count} žiūrėtojas} few {Įtraukti {count} žiūrėtojai} many {Įtraukta {count} žiūrėtojo} =0 {Įtraukta 0 žiūrėtojų} =1 {Įtrauktas 1 žiūrėtojas} other {Įtraukta {count} žiūrėtojų}}",
|
||||
"viewersSuccessfullyAdded": "{count, plural, =0 {Įtraukta 0 žiūrėtojų} =1 {Įtrauktas 1 žiūrėtojas} other {Įtraukta {count} žiūrėtojų}}",
|
||||
"@viewersSuccessfullyAdded": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1488,7 +1484,7 @@
|
||||
},
|
||||
"currentlyRunning": "šiuo metu vykdoma",
|
||||
"ignored": "ignoruota",
|
||||
"photosCount": "{count, plural, one {{count} nuotrauka} few {{count} nuotraukos} many {{count} nuotraukos} =0 {0 nuotraukų} =1 {1 nuotrauka} other {{count} nuotraukų}}",
|
||||
"photosCount": "{count, plural, =0 {0 nuotraukų} =1 {1 nuotrauka} other {{count} nuotraukų}}",
|
||||
"@photosCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1686,21 +1682,11 @@
|
||||
"moveSelectedPhotosToOneDate": "Perkelti pasirinktas nuotraukas į vieną datą",
|
||||
"shiftDatesAndTime": "Pastumti datas ir laiką",
|
||||
"photosKeepRelativeTimeDifference": "Nuotraukos išlaiko santykinį laiko skirtumą",
|
||||
"photocountPhotos": "{count, plural, =0 {Nėra nuotraukų} =1 {1 nuotrauka} other {{count} nuotraukų}}",
|
||||
"@photocountPhotos": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int",
|
||||
"example": "2"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appIcon": "Programos piktograma",
|
||||
"notThisPerson": "Ne šis asmuo?",
|
||||
"selectedItemsWillBeRemovedFromThisPerson": "Pasirinkti elementai bus pašalinti iš šio asmens, bet nebus ištrinti iš jūsų bibliotekos.",
|
||||
"throughTheYears": "{dateFormat} per metus",
|
||||
"thisWeekThroughTheYears": "Ši savaitė per metus",
|
||||
"thisWeekXYearsAgo": "{count, plural, =1 {Šią savaitę, prieš {count} metus} other {Šią savaitę, prieš {count} metų}}",
|
||||
"youAndThem": "Jūs ir {name}",
|
||||
"admiringThem": "Žavisi {name}",
|
||||
"embracingThem": "Apkabinat {name}",
|
||||
@@ -1830,4 +1816,4 @@
|
||||
"size": "Dydis",
|
||||
"similarity": "Panašumas",
|
||||
"processingLocally": "Apdorojama vietoje"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,7 +477,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "Toon herinneringen",
|
||||
"yearsAgo": "{count, plural, one{{count} jaar geleden} other{{count} jaar geleden}}",
|
||||
"yearsAgo": "{count, plural, other{{count} jaar geleden}}",
|
||||
"backupSettings": "Back-up instellingen",
|
||||
"backupStatus": "Back-up status",
|
||||
"backupStatusDescription": "Items die zijn geback-upt, worden hier getoond",
|
||||
@@ -1773,4 +1773,4 @@
|
||||
"areYouSureYouWantToMergeThem": "Weet je zeker dat je ze wilt samenvoegen?",
|
||||
"allUnnamedGroupsWillBeMergedIntoTheSelectedPerson": "Alle naamloze groepen worden samengevoegd met de geselecteerde persoon. Dit kan nog steeds ongedaan worden gemaakt vanuit het geschiedenisoverzicht van de persoon.",
|
||||
"yesIgnore": "Ja, negeer"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@
|
||||
"deleteFromBoth": "Slett frå begge",
|
||||
"newAlbum": "Nytt album",
|
||||
"albums": "Albums",
|
||||
"memoryCount": "{count, plural, =0{ingen minne} one{{formattedCount} minne} other{{formattedCount} minne}}",
|
||||
"memoryCount": "{count, plural, =0{ingen minne} other{{formattedCount} minne}}",
|
||||
"@memoryCount": {
|
||||
"description": "The text to display the number of memories",
|
||||
"type": "text",
|
||||
@@ -310,4 +310,4 @@
|
||||
"adjust": "Juster",
|
||||
"draw": "Klistremerke",
|
||||
"brushColor": "Penselfarge"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,7 +477,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "Vis minner",
|
||||
"yearsAgo": "{count, plural, one{{count} år siden} other{{count} år siden}}",
|
||||
"yearsAgo": "{count, plural, other{{count} år siden}}",
|
||||
"backupSettings": "Sikkerhetskopier innstillinger",
|
||||
"backupStatus": "Status for sikkerhetskopi",
|
||||
"backupStatusDescription": "Elementer som har blitt sikkerhetskopiert vil vises her",
|
||||
@@ -1737,4 +1737,4 @@
|
||||
"memoriesWidgetDesc": "Velg typen minner du ønsker å se på din hjemskjerm.",
|
||||
"smartMemories": "Smarte minner",
|
||||
"pastYearsMemories": "Tidligere års minner"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"deleteFromBoth": "Usuń z obu",
|
||||
"newAlbum": "Nowy album",
|
||||
"albums": "Albumy",
|
||||
"memoryCount": "{count, plural, =0{brak wspomnień} one{{formattedCount} wspomnienie} few{{formattedCount} wspomnienia} many{{formattedCount} wspomnień} other{{formattedCount} wspomnień}}",
|
||||
"memoryCount": "{count, plural, =0{brak wspomnień} one{{formattedCount} wspomnienie} other{{formattedCount} wspomnień}}",
|
||||
"@memoryCount": {
|
||||
"description": "The text to display the number of memories",
|
||||
"type": "text",
|
||||
@@ -459,8 +459,8 @@
|
||||
"selectAll": "Zaznacz wszystko",
|
||||
"skip": "Pomiń",
|
||||
"updatingFolderSelection": "Aktualizowanie wyboru folderu...",
|
||||
"itemCount": "{count, plural, one{{count} element} few {{count} elementy} many {{count} elementów} other{{count} elementu}}",
|
||||
"deleteItemCount": "{count, plural, =1 {Usuń {count} element} few {Usuń {count} elementy} many {Usuń {count} elementów} other{Usuń {count} elementu}}",
|
||||
"itemCount": "{count, plural, one{{count} element} other{{count} elementu}}",
|
||||
"deleteItemCount": "{count, plural, =1 {Usuń {count} element} other{Usuń {count} elementu}}",
|
||||
"duplicateItemsGroup": "{count} plików, każdy po {formattedSize}",
|
||||
"@duplicateItemsGroup": {
|
||||
"description": "Display the number of duplicate files and their size",
|
||||
@@ -477,7 +477,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "Pokaż wspomnienia",
|
||||
"yearsAgo": "{count, plural, one{{count} rok temu} few {{count} lata temu} many {{count} lat temu} other{{count} lata temu}}",
|
||||
"yearsAgo": "{count, plural, one{{count} rok temu} other{{count} lata temu}}",
|
||||
"backupSettings": "Ustawienia kopii zapasowej",
|
||||
"backupStatus": "Status kopii zapasowej",
|
||||
"backupStatusDescription": "Elementy, których kopia zapasowa została utworzona, zostaną wyświetlone w tym miejscu",
|
||||
@@ -794,11 +794,11 @@
|
||||
"share": "Udostępnij",
|
||||
"unhideToAlbum": "Odkryj do albumu",
|
||||
"restoreToAlbum": "Przywróć do albumu",
|
||||
"moveItem": "{count, plural, =1 {Przenieś element} few {Przenieś elementy} many {Przenieś elementów} other {Przenieś elementów}}",
|
||||
"moveItem": "{count, plural, =1 {Przenieś element} other {Przenieś elementów}}",
|
||||
"@moveItem": {
|
||||
"description": "Page title while moving one or more items to an album"
|
||||
},
|
||||
"addItem": "{count, plural, =1 {Dodaj element} few {Dodaj elementy} many {Dodaj elementów} other {Dodaj elementów}}",
|
||||
"addItem": "{count, plural, =1 {Dodaj element} other {Dodaj elementów}}",
|
||||
"@addItem": {
|
||||
"description": "Page title while adding one or more items to album"
|
||||
},
|
||||
@@ -826,7 +826,7 @@
|
||||
"referFriendsAnd2xYourPlan": "Poleć znajomym i podwój swój plan",
|
||||
"shareAlbumHint": "Otwórz album i dotknij przycisk udostępniania w prawym górnym rogu, aby udostępnić.",
|
||||
"itemsShowTheNumberOfDaysRemainingBeforePermanentDeletion": "Elementy pokazują liczbę dni pozostałych przed trwałym usunięciem",
|
||||
"trashDaysLeft": "{count, plural, =0 {Wkrótce} =1{1 dzień} few {{count} dni} other{{count} dni}}",
|
||||
"trashDaysLeft": "{count, plural, =0 {Wkrótce} =1{1 dzień} other{{count} dni}}",
|
||||
"@trashDaysLeft": {
|
||||
"description": "Text to indicate number of days remaining before permanent deletion",
|
||||
"placeholders": {
|
||||
@@ -899,7 +899,7 @@
|
||||
"authToViewYourMemories": "Prosimy uwierzytelnić się, aby wyświetlić swoje wspomnienia",
|
||||
"unlock": "Odblokuj",
|
||||
"freeUpSpace": "Zwolnij miejsce",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {Może zostać usunięty z urządzenia, aby zwolnić {formattedSize}} many {Może być usuniętych z urządzenia, aby zwolnić {formattedSize}} other {Mogą być usunięte z urządzenia, aby zwolnić {formattedSize}}}",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {Może zostać usunięty z urządzenia, aby zwolnić {formattedSize}} other {Mogą być usunięte z urządzenia, aby zwolnić {formattedSize}}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {1 plikowi} other {{formattedNumber} plikom}} w tym albumie została bezpiecznie utworzona kopia zapasowa",
|
||||
"@filesBackedUpInAlbum": {
|
||||
"description": "Text to tell user how many files have been backed up in the album",
|
||||
@@ -1217,7 +1217,7 @@
|
||||
"searchHint4": "Lokalizacja",
|
||||
"searchHint5": "Wkrótce: Twarze i magiczne wyszukiwanie ✨",
|
||||
"addYourPhotosNow": "Dodaj swoje zdjęcia teraz",
|
||||
"searchResultCount": "{count, plural, one{Znaleziono {count} wynik} few {Znaleziono {count} wyniki} other{Znaleziono {count} wyników}}",
|
||||
"searchResultCount": "{count, plural, one{Znaleziono {count} wynik} other{Znaleziono {count} wyników}}",
|
||||
"@searchResultCount": {
|
||||
"description": "Text to tell user how many results were found for their search query",
|
||||
"placeholders": {
|
||||
@@ -1488,7 +1488,7 @@
|
||||
},
|
||||
"currentlyRunning": "aktualnie uruchomiony",
|
||||
"ignored": "ignorowane",
|
||||
"photosCount": "{count, plural, =0 {0 zdjęć} =1 {1 zdjęcie} few {{count} zdjęcia} many {{count} zdjęć} other {{count} zdjęć}}",
|
||||
"photosCount": "{count, plural, =0 {0 zdjęć} =1 {1 zdjęcie} other {{count} zdjęć}}",
|
||||
"@photosCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1686,7 +1686,7 @@
|
||||
"moveSelectedPhotosToOneDate": "Przenieś wybrane zdjęcia na jedną datę",
|
||||
"shiftDatesAndTime": "Zmień daty i czas",
|
||||
"photosKeepRelativeTimeDifference": "Zdjęcia zachowują względną różnicę czasu",
|
||||
"photocountPhotos": "{count, plural, =0 {Brak zdjęć} =1 {1 zdjęcie} few {{count} zdjęcia} many {{count} zdjęć} other {{count} zdjęć}}",
|
||||
"photocountPhotos": "{count, plural, =0 {Brak zdjęć} =1 {1 zdjęcie} other {{count} zdjęć}}",
|
||||
"@photocountPhotos": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1700,7 +1700,7 @@
|
||||
"selectedItemsWillBeRemovedFromThisPerson": "Wybrane elementy zostaną usunięte z tej osoby, ale nie zostaną usunięte z Twojej biblioteki.",
|
||||
"throughTheYears": "{dateFormat} przez lata",
|
||||
"thisWeekThroughTheYears": "Ten tydzień przez lata",
|
||||
"thisWeekXYearsAgo": "{count, plural, =1 {W tym tygodniu, {count} rok temu} few {W tym tygodniu, {count} lata temu} many {W tym tygodniu, {count} lat temu} other {W tym tygodniu, {count} lat temu}}",
|
||||
"thisWeekXYearsAgo": "{count, plural, =1 {W tym tygodniu, {count} rok temu} other {W tym tygodniu, {count} lat temu}}",
|
||||
"youAndThem": "Ty i {name}",
|
||||
"admiringThem": "Podziwianie {name}",
|
||||
"embracingThem": "Obejmowanie {name}",
|
||||
@@ -1828,4 +1828,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1220,15 +1220,17 @@
|
||||
"@findThemQuickly": {
|
||||
"description": "Subtitle to indicate that the user can find people quickly by name"
|
||||
},
|
||||
"findPeopleByName": "Encontrar pessoas rapidamente pelo nome",
|
||||
"longPressAnEmailToVerifyEndToEndEncryption": "Pressione e segure um e-mail para verificar a criptografia de ponta a ponta.",
|
||||
"developerSettingsWarning": "Tem a certeza de que pretende modificar as definições de programador?",
|
||||
"developerSettings": "Definições do programador",
|
||||
"serverEndpoint": "Endpoint do servidor",
|
||||
"invalidEndpoint": "Endpoint inválido",
|
||||
"invalidEndpointMessage": "Desculpe, o endpoint que introduziu é inválido. Introduza um ponto final válido e tente novamente.",
|
||||
"endpointUpdatedMessage": "Endpoint atualizado com sucesso",
|
||||
"customEndpoint": "Conectado a {endpoint}",
|
||||
"findPeopleByName": "Busque pessoas facilmente pelo nome",
|
||||
"addViewers": "{count, plural, one {Adicionar visualizador} other {Adicionar visualizadores}}",
|
||||
"addCollaborators": "{count, plural, one {Adicionar colaborador} other {Adicionar colaboradores}}",
|
||||
"longPressAnEmailToVerifyEndToEndEncryption": "Pressione um e-mail para verificar a criptografia ponta a ponta.",
|
||||
"developerSettingsWarning": "Deseja modificar as Opções de Desenvolvedor?",
|
||||
"developerSettings": "Opções de desenvolvedor",
|
||||
"serverEndpoint": "Ponto final do servidor",
|
||||
"invalidEndpoint": "Ponto final inválido",
|
||||
"invalidEndpointMessage": "Desculpe, o ponto final inserido é inválido. Insira um ponto final válido e tente novamente.",
|
||||
"endpointUpdatedMessage": "Ponto final atualizado com sucesso",
|
||||
"customEndpoint": "Conectado à {endpoint}",
|
||||
"createCollaborativeLink": "Criar link colaborativo",
|
||||
"search": "Pesquisar",
|
||||
"enterPersonName": "Inserir nome da pessoa",
|
||||
|
||||
@@ -242,7 +242,7 @@
|
||||
"publicLinkEnabled": "Link público ativado",
|
||||
"shareALink": "Partilhar um link",
|
||||
"sharedAlbumSectionDescription": "Criar álbuns compartilhados e colaborativos com outros usuários da Ente, incluindo usuários em planos gratuitos.",
|
||||
"shareWithPeopleSectionTitle": "{numberOfPeople, plural, one {}=0 {Compartilhe com pessoas específicas} =1 {Compartilhado com 1 pessoa} other {Compartilhado com {numberOfPeople} pessoas}}",
|
||||
"shareWithPeopleSectionTitle": "{numberOfPeople, plural, =0 {Compartilhe com pessoas específicas} =1 {Compartilhado com 1 pessoa} other {Compartilhado com {numberOfPeople} pessoas}}",
|
||||
"@shareWithPeopleSectionTitle": {
|
||||
"placeholders": {
|
||||
"numberOfPeople": {
|
||||
@@ -899,7 +899,7 @@
|
||||
"authToViewYourMemories": "Por favor, autentique-se para ver suas memórias",
|
||||
"unlock": "Desbloquear",
|
||||
"freeUpSpace": "Libertar espaço",
|
||||
"freeUpSpaceSaving": "{count, plural, one {}=1 {Pode eliminá-lo do aparelho para esvaziar {formattedSize}} other {Pode eliminá-los do aparelho para esvaziar {formattedSize}}}",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {Pode eliminá-lo do aparelho para esvaziar {formattedSize}} other {Pode eliminá-los do aparelho para esvaziar {formattedSize}}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {1 arquivo} other {{formattedNumber} arquivos}} neste álbum teve um backup seguro",
|
||||
"@filesBackedUpInAlbum": {
|
||||
"description": "Text to tell user how many files have been backed up in the album",
|
||||
@@ -1828,4 +1828,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,7 +443,7 @@
|
||||
"selectAll": "Selectare totală",
|
||||
"skip": "Omiteți",
|
||||
"updatingFolderSelection": "Se actualizează selecția dosarelor...",
|
||||
"itemCount": "{count, plural, one{{count} articol} few {{count} articole} other{{count} de articole}}",
|
||||
"itemCount": "{count, plural, one{{count} articol} other{{count} de articole}}",
|
||||
"deleteItemCount": "{count, plural, =1 {Ștergeți {count} articol} other {Ștergeți {count} de articole}}",
|
||||
"duplicateItemsGroup": "{count} fișiere, {formattedSize} fiecare",
|
||||
"@duplicateItemsGroup": {
|
||||
@@ -461,7 +461,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "Afișare amintiri",
|
||||
"yearsAgo": "{count, plural, one{acum {count} an} few {acum {count} ani} other{acum {count} de ani}}",
|
||||
"yearsAgo": "{count, plural, one{acum {count} an} other{acum {count} de ani}}",
|
||||
"backupSettings": "Setări copie de rezervă",
|
||||
"backupStatus": "Stare copie de rezervă",
|
||||
"backupStatusDescription": "Articolele care au fost salvate vor apărea aici",
|
||||
@@ -526,7 +526,7 @@
|
||||
},
|
||||
"remindToEmptyEnteTrash": "De asemenea, goliți „Coșul de gunoi” pentru a revendica spațiul eliberat",
|
||||
"sparkleSuccess": "✨ Succes",
|
||||
"duplicateFileCountWithStorageSaved": "Ați curățat {count, plural, one{{count} dublură} few {{count} dubluri} other{{count} de dubluri}}, economisind ({storageSaved}!)",
|
||||
"duplicateFileCountWithStorageSaved": "Ați curățat {count, plural, one{{count} dublură} other{{count} de dubluri}}, economisind ({storageSaved}!)",
|
||||
"@duplicateFileCountWithStorageSaved": {
|
||||
"description": "The text to display when the user has successfully cleaned up duplicate files",
|
||||
"type": "text",
|
||||
@@ -873,7 +873,7 @@
|
||||
"authToViewYourMemories": "Vă rugăm să vă autentificați pentru a vă vizualiza amintirile",
|
||||
"unlock": "Deblocare",
|
||||
"freeUpSpace": "Eliberați spațiu",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {Un fișier din acest album a fost deja salvat în siguranță} few {{formattedNumber} fișiere din acest album au fost deja salvate în siguranță} other {{formattedNumber} de fișiere din acest album au fost deja salvate în siguranță}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {Un fișier din acest album a fost deja salvat în siguranță} other {{formattedNumber} de fișiere din acest album au fost deja salvate în siguranță}}",
|
||||
"@filesBackedUpInAlbum": {
|
||||
"description": "Text to tell user how many files have been backed up in the album",
|
||||
"placeholders": {
|
||||
@@ -888,7 +888,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"filesBackedUpFromDevice": "{count, plural, one {Un fișier de pe acest dispozitiv a fost deja salvat în siguranță} few {{formattedNumber} fișiere de pe acest dispozitiv au fost deja salvate în siguranță} other {{formattedNumber} de fișiere de pe acest dispozitiv fost deja salvate în siguranță}}",
|
||||
"filesBackedUpFromDevice": "{count, plural, one {Un fișier de pe acest dispozitiv a fost deja salvat în siguranță} other {{formattedNumber} de fișiere de pe acest dispozitiv fost deja salvate în siguranță}}",
|
||||
"@filesBackedUpFromDevice": {
|
||||
"description": "Text to tell user how many files have been backed up from this device",
|
||||
"placeholders": {
|
||||
@@ -1177,7 +1177,7 @@
|
||||
"searchHint4": "Locație",
|
||||
"searchHint5": "În curând: chipuri și căutare magică ✨",
|
||||
"addYourPhotosNow": "Adăugați-vă fotografiile acum",
|
||||
"searchResultCount": "{count, plural, one{{count} rezultat găsit} few {{count} rezultate găsite} other{{count} de rezultate găsite}}",
|
||||
"searchResultCount": "{count, plural, one{{count} rezultat găsit} other{{count} de rezultate găsite}}",
|
||||
"@searchResultCount": {
|
||||
"description": "Text to tell user how many results were found for their search query",
|
||||
"placeholders": {
|
||||
@@ -1522,4 +1522,4 @@
|
||||
"joinAlbumSubtext": "pentru a vedea și a adăuga fotografii",
|
||||
"joinAlbumSubtextViewer": "pentru a adăuga la albumele distribuite",
|
||||
"join": "Alăturare"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,7 +477,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "Показывать воспоминания",
|
||||
"yearsAgo": "{count, plural, one{{count} год назад} few{{count} года назад} other{{count} лет назад}}",
|
||||
"yearsAgo": "{count, plural, one{{count} год назад} other{{count} лет назад}}",
|
||||
"backupSettings": "Настройки резервного копирования",
|
||||
"backupStatus": "Статус резервного копирования",
|
||||
"backupStatusDescription": "Элементы, сохранённые в резервной копии, появятся здесь",
|
||||
@@ -1786,4 +1786,4 @@
|
||||
"day": "День",
|
||||
"filter": "Фильтр",
|
||||
"font": "Шрифт"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,7 +445,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "Прикажи успомене",
|
||||
"yearsAgo": "{count, plural, one{{count} година уназад} few {{count} године уназад} other{{count} година уназад}}",
|
||||
"yearsAgo": "{count, plural, other{{count} година уназад}}",
|
||||
"backupStatus": "Статус резервних копија",
|
||||
"backupOverMobileData": "Копирај користећи мобилни интернет",
|
||||
"backupVideos": "Копирај видео снимке",
|
||||
@@ -496,7 +496,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"duplicateFileCountWithStorageSaved": "Обрисали сте {count, plural, one{{count} дупликат} few {{count} дупликата} other{{count} дупликата}}, ослобађам ({storageSaved}!)",
|
||||
"duplicateFileCountWithStorageSaved": "Обрисали сте {count, plural, one{{count} дупликат} other{{count} дупликата}}, ослобађам ({storageSaved}!)",
|
||||
"@duplicateFileCountWithStorageSaved": {
|
||||
"description": "The text to display when the user has successfully cleaned up duplicate files",
|
||||
"type": "text",
|
||||
@@ -921,4 +921,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,7 +459,7 @@
|
||||
"selectAll": "Markera allt",
|
||||
"skip": "Hoppa över",
|
||||
"updatingFolderSelection": "Uppdaterar mappval...",
|
||||
"itemCount": "{count, plural, one{{count} objekt} other{{count} objekt}}",
|
||||
"itemCount": "{count, plural, other{{count} objekt}}",
|
||||
"deleteItemCount": "{count, plural, =1 {Radera {count} objekt} other {Radera {count} objekt}}",
|
||||
"duplicateItemsGroup": "{count} filer, {formattedSize} vardera",
|
||||
"@duplicateItemsGroup": {
|
||||
@@ -477,7 +477,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "Visa minnen",
|
||||
"yearsAgo": "{count, plural, one{{count} år sedan} other{{count} år sedan}}",
|
||||
"yearsAgo": "{count, plural, other{{count} år sedan}}",
|
||||
"backupSettings": "Säkerhetskopieringsinställningar",
|
||||
"backupStatus": "Säkerhetskopieringsstatus",
|
||||
"backupStatusDescription": "Objekt som har säkerhetskopierats kommer att visas här",
|
||||
@@ -619,7 +619,7 @@
|
||||
"viewAll": "Visa alla",
|
||||
"inviteYourFriendsToEnte": "Bjud in dina vänner till Ente",
|
||||
"fileTypes": "Filtyper",
|
||||
"searchResultCount": "{count, plural, one{{count} resultat hittades} other{{count} resultat hittades}}",
|
||||
"searchResultCount": "{count, plural, other{{count} resultat hittades}}",
|
||||
"@searchResultCount": {
|
||||
"description": "Text to tell user how many results were found for their search query",
|
||||
"placeholders": {
|
||||
@@ -655,4 +655,4 @@
|
||||
"newPerson": "Ny person",
|
||||
"addName": "Lägg till namn",
|
||||
"add": "Lägg till"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"deleteFromBoth": "Her ikisinden de sil",
|
||||
"newAlbum": "Yeni albüm",
|
||||
"albums": "Albümler",
|
||||
"memoryCount": "{count, plural, =0{hiç anı yok} one{{formattedCount} anı} other{{formattedCount} anı}}",
|
||||
"memoryCount": "{count, plural, =0{hiç anı yok} other{{formattedCount} anı}}",
|
||||
"@memoryCount": {
|
||||
"description": "The text to display the number of memories",
|
||||
"type": "text",
|
||||
@@ -477,7 +477,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "Anıları göster",
|
||||
"yearsAgo": "{count, plural, one{{count} yıl önce} other{{count} yıl önce}}",
|
||||
"yearsAgo": "{count, plural, other{{count} yıl önce}}",
|
||||
"backupSettings": "Yedekleme seçenekleri",
|
||||
"backupStatus": "Yedekleme durumu",
|
||||
"backupStatusDescription": "Eklenen öğeler burada görünecek",
|
||||
@@ -1217,7 +1217,7 @@
|
||||
"searchHint4": "Konum",
|
||||
"searchHint5": "Çok yakında: Yüzler ve sihirli arama ✨",
|
||||
"addYourPhotosNow": "Fotoğraflarınızı şimdi ekleyin",
|
||||
"searchResultCount": "{count, plural, one{{count} yıl önce} other{{count} yıl önce}}",
|
||||
"searchResultCount": "{count, plural, other{{count} yıl önce}}",
|
||||
"@searchResultCount": {
|
||||
"description": "Text to tell user how many results were found for their search query",
|
||||
"placeholders": {
|
||||
@@ -1777,4 +1777,4 @@
|
||||
"different": "Farklı",
|
||||
"sameperson": "Aynı kişi mi?",
|
||||
"indexingPausedStatusDescription": "Dizin oluşturma duraklatıldı. Cihaz hazır olduğunda otomatik olarak devam edecektir. Cihaz, pil seviyesi, pil sağlığı ve termal durumu sağlıklı bir aralıkta olduğunda hazır kabul edilir."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,7 +438,7 @@
|
||||
"selectAll": "Вибрати все",
|
||||
"skip": "Пропустити",
|
||||
"updatingFolderSelection": "Оновлення вибору теки...",
|
||||
"itemCount": "{count, plural, one{{count} елемент} few {{count} елементи} many {{count} елементів} other{{count} елементів}}",
|
||||
"itemCount": "{count, plural, one{{count} елемент} other{{count} елементів}}",
|
||||
"deleteItemCount": "{count, plural, =1 {Видалено {count} елемент} other {Видалено {count} елементів}}",
|
||||
"duplicateItemsGroup": "{count} файлів, кожен по {formattedSize}",
|
||||
"@duplicateItemsGroup": {
|
||||
@@ -1172,7 +1172,7 @@
|
||||
"searchHint4": "Розташування",
|
||||
"searchHint5": "Незабаром: Обличчя і магічний пошук ✨",
|
||||
"addYourPhotosNow": "Додайте свої фотографії",
|
||||
"searchResultCount": "{count, plural, one{Знайдено {count} результат} few {Знайдено {count} результати} many {Знайдено {count} результатів} other{Знайдено {count} результати}}",
|
||||
"searchResultCount": "{count, plural, one{Знайдено {count} результат} other{Знайдено {count} результати}}",
|
||||
"@searchResultCount": {
|
||||
"description": "Text to tell user how many results were found for their search query",
|
||||
"placeholders": {
|
||||
@@ -1510,4 +1510,4 @@
|
||||
"legacyInvite": "{email} запросив вас стати довіреною особою",
|
||||
"authToManageLegacy": "Авторизуйтесь, щоби керувати довіреними контактами",
|
||||
"useDifferentPlayerInfo": "Виникли проблеми з відтворенням цього відео? Натисніть і утримуйте тут, щоб спробувати інший плеєр."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,7 +459,7 @@
|
||||
"selectAll": "全选",
|
||||
"skip": "跳过",
|
||||
"updatingFolderSelection": "正在更新文件夹选择...",
|
||||
"itemCount": "{count, plural, one{{count} 个项目} other{{count} 个项目}}",
|
||||
"itemCount": "{count, plural, other{{count} 个项目}}",
|
||||
"deleteItemCount": "{count, plural, =1 {删除 {count} 个项目} other {删除 {count} 个项目}}",
|
||||
"duplicateItemsGroup": "{count} 个文件,每个文件 {formattedSize}",
|
||||
"@duplicateItemsGroup": {
|
||||
@@ -477,7 +477,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "显示回忆",
|
||||
"yearsAgo": "{count, plural, one{{count} 年前} other{{count} 年前}}",
|
||||
"yearsAgo": "{count, plural, other{{count} 年前}}",
|
||||
"backupSettings": "备份设置",
|
||||
"backupStatus": "备份状态",
|
||||
"backupStatusDescription": "已备份的项目将显示在此处",
|
||||
@@ -1934,4 +1934,4 @@
|
||||
"related": "相关",
|
||||
"hoorayyyy": "耶~~!",
|
||||
"nothingToTidyUpHere": "这里没什么可清理的"
|
||||
}
|
||||
}
|
||||
|
||||
3
mobile/apps/photos/lib/log/devlog.dart
Normal file
3
mobile/apps/photos/lib/log/devlog.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
import "dart:developer";
|
||||
|
||||
var devLog = log;
|
||||
@@ -25,12 +25,14 @@ import "package:photos/db/ml/db.dart";
|
||||
import 'package:photos/ente_theme_data.dart';
|
||||
import "package:photos/extensions/stop_watch.dart";
|
||||
import "package:photos/l10n/l10n.dart";
|
||||
import 'package:photos/module/upload/service/file_uploader.dart';
|
||||
import "package:photos/service_locator.dart";
|
||||
import "package:photos/services/account/user_service.dart";
|
||||
import 'package:photos/services/app_lifecycle_service.dart';
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import 'package:photos/services/favorites_service.dart';
|
||||
import 'package:photos/services/home_widget_service.dart';
|
||||
import "package:photos/services/local/import/local_import.dart";
|
||||
import 'package:photos/services/local_file_update_service.dart';
|
||||
import "package:photos/services/machine_learning/face_ml/person/person_service.dart";
|
||||
import 'package:photos/services/machine_learning/ml_service.dart';
|
||||
@@ -38,7 +40,6 @@ import 'package:photos/services/machine_learning/semantic_search/semantic_search
|
||||
import "package:photos/services/notification_service.dart";
|
||||
import 'package:photos/services/push_service.dart';
|
||||
import 'package:photos/services/search_service.dart';
|
||||
import 'package:photos/services/sync/local_sync_service.dart';
|
||||
import 'package:photos/services/sync/remote_sync_service.dart';
|
||||
import "package:photos/services/sync/sync_service.dart";
|
||||
import "package:photos/services/video_preview_service.dart";
|
||||
@@ -47,7 +48,6 @@ import "package:photos/src/rust/frb_generated.dart";
|
||||
import 'package:photos/ui/tools/app_lock.dart';
|
||||
import 'package:photos/ui/tools/lock_screen.dart';
|
||||
import "package:photos/utils/email_util.dart";
|
||||
import 'package:photos/utils/file_uploader.dart';
|
||||
import "package:photos/utils/lock_screen_settings.dart";
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -167,7 +167,7 @@ Future<void> _runMinimally(String taskId, TimeLogger tlog) async {
|
||||
// Upload & Sync Related
|
||||
await FileUploader.instance.init(prefs, true);
|
||||
LocalFileUpdateService.instance.init(prefs);
|
||||
await LocalSyncService.instance.init(prefs);
|
||||
await LocalImportService.instance.init(prefs);
|
||||
RemoteSyncService.instance.init(prefs);
|
||||
await SyncService.instance.init(prefs);
|
||||
|
||||
@@ -258,7 +258,7 @@ Future<void> _init(bool isBackground, {String via = ''}) async {
|
||||
_logger.info("FileUploader init done $tlog");
|
||||
|
||||
_logger.info("LocalSyncService init $tlog");
|
||||
await LocalSyncService.instance.init(preferences);
|
||||
await LocalImportService.instance.init(preferences);
|
||||
_logger.info("LocalSyncService init done $tlog");
|
||||
|
||||
RemoteSyncService.instance.init(preferences);
|
||||
@@ -349,9 +349,9 @@ Future runWithLogs(Function() function, {String prefix = ""}) async {
|
||||
body: function,
|
||||
logDirPath: (await getApplicationSupportDirectory()).path + "/logs",
|
||||
maxLogFiles: 5,
|
||||
sentryDsn: kDebugMode ? sentryDebugDSN : sentryDSN,
|
||||
sentryDsn: kDebugMode ? null : sentryDSN,
|
||||
tunnel: sentryTunnel,
|
||||
enableInDebugMode: true,
|
||||
enableInDebugMode: !kDebugMode, // todo: rewrite neeraj revert this
|
||||
prefix: prefix,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class CollectionFileItem {
|
||||
final int id;
|
||||
final String encryptedKey;
|
||||
final String keyDecryptionNonce;
|
||||
|
||||
CollectionFileItem(
|
||||
this.id,
|
||||
this.encryptedKey,
|
||||
this.keyDecryptionNonce,
|
||||
);
|
||||
|
||||
CollectionFileItem copyWith({
|
||||
int? id,
|
||||
String? encryptedKey,
|
||||
String? keyDecryptionNonce,
|
||||
}) {
|
||||
return CollectionFileItem(
|
||||
id ?? this.id,
|
||||
encryptedKey ?? this.encryptedKey,
|
||||
keyDecryptionNonce ?? this.keyDecryptionNonce,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'encryptedKey': encryptedKey,
|
||||
'keyDecryptionNonce': keyDecryptionNonce,
|
||||
};
|
||||
}
|
||||
|
||||
static fromMap(Map<String, dynamic>? map) {
|
||||
if (map == null) return null;
|
||||
|
||||
return CollectionFileItem(
|
||||
map['id'],
|
||||
map['encryptedKey'],
|
||||
map['keyDecryptionNonce'],
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory CollectionFileItem.fromJson(String source) =>
|
||||
CollectionFileItem.fromMap(json.decode(source));
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'CollectionFileItem(id: $id, encryptedKey: $encryptedKey, keyDecryptionNonce: $keyDecryptionNonce)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object o) {
|
||||
if (identical(this, o)) return true;
|
||||
|
||||
return o is CollectionFileItem &&
|
||||
o.id == id &&
|
||||
o.encryptedKey == encryptedKey &&
|
||||
o.keyDecryptionNonce == keyDecryptionNonce;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
id.hashCode ^ encryptedKey.hashCode ^ keyDecryptionNonce.hashCode;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:ente_crypto/ente_crypto.dart";
|
||||
|
||||
class CollectionFileRequest {
|
||||
final int id;
|
||||
final String encryptedKey;
|
||||
final String keyDecryptionNonce;
|
||||
|
||||
CollectionFileRequest(
|
||||
this.id,
|
||||
this.encryptedKey,
|
||||
this.keyDecryptionNonce,
|
||||
);
|
||||
|
||||
static Map<String, dynamic> req(
|
||||
int id, {
|
||||
required Uint8List encKey,
|
||||
required Uint8List encKeyNonce,
|
||||
}) {
|
||||
return {
|
||||
'id': id,
|
||||
'encryptedKey': CryptoUtil.bin2base64(encKey),
|
||||
'keyDecryptionNonce': CryptoUtil.bin2base64(encKeyNonce),
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'encryptedKey': encryptedKey,
|
||||
'keyDecryptionNonce': keyDecryptionNonce,
|
||||
};
|
||||
}
|
||||
}
|
||||
197
mobile/apps/photos/lib/models/api/diff/diff.dart
Normal file
197
mobile/apps/photos/lib/models/api/diff/diff.dart
Normal file
@@ -0,0 +1,197 @@
|
||||
import "dart:convert";
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:photos/models/api/diff/trash_time.dart";
|
||||
import "package:photos/models/file/remote/asset.dart";
|
||||
|
||||
class Info {
|
||||
final int fileSize;
|
||||
final int thumbSize;
|
||||
|
||||
static Info? fromJson(Map<String, dynamic>? json) {
|
||||
if (json == null) return null;
|
||||
return Info(
|
||||
fileSize: json['fileSize'] ?? -1,
|
||||
thumbSize: json['thumbSize'] ?? -1,
|
||||
);
|
||||
}
|
||||
|
||||
Info({required this.fileSize, required this.thumbSize});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'fileSize': fileSize,
|
||||
'thumbSize': thumbSize,
|
||||
};
|
||||
}
|
||||
|
||||
String toEncodedJson() {
|
||||
return jsonEncode(toJson());
|
||||
}
|
||||
|
||||
static Info? fromEncodedJson(String? encodedJson) {
|
||||
if (encodedJson == null) return null;
|
||||
return Info.fromJson(jsonDecode(encodedJson));
|
||||
}
|
||||
}
|
||||
|
||||
class Metadata {
|
||||
final Map<String, dynamic> data;
|
||||
final int version;
|
||||
|
||||
Metadata({required this.data, required this.version});
|
||||
|
||||
static fromJson(Map<String, dynamic> json) {
|
||||
if (json.isEmpty || json['data'] == null) return null;
|
||||
return Metadata(
|
||||
data: json['data'],
|
||||
version: json['version'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'data': data,
|
||||
'version': version,
|
||||
};
|
||||
}
|
||||
|
||||
static Metadata? fromEncodedJson(String? encodedJson) {
|
||||
if (encodedJson == null) return null;
|
||||
return Metadata.fromJson(jsonDecode(encodedJson));
|
||||
}
|
||||
|
||||
String toEncodedJson() {
|
||||
return jsonEncode(toJson());
|
||||
}
|
||||
}
|
||||
|
||||
class ApiFileItem {
|
||||
final int fileID;
|
||||
final int ownerID;
|
||||
final Uint8List? thumnailDecryptionHeader;
|
||||
final Uint8List? fileDecryptionHeader;
|
||||
final Metadata? metadata;
|
||||
final Metadata? privMagicMetadata;
|
||||
final Metadata? pubMagicMetadata;
|
||||
final Info? info;
|
||||
|
||||
ApiFileItem({
|
||||
required this.fileID,
|
||||
required this.ownerID,
|
||||
this.thumnailDecryptionHeader,
|
||||
this.fileDecryptionHeader,
|
||||
this.metadata,
|
||||
this.privMagicMetadata,
|
||||
this.pubMagicMetadata,
|
||||
this.info,
|
||||
});
|
||||
|
||||
factory ApiFileItem.deleted(int fileID, int ownerID) {
|
||||
return ApiFileItem(
|
||||
fileID: fileID,
|
||||
ownerID: ownerID,
|
||||
);
|
||||
}
|
||||
|
||||
List<Object?> filesMetadataRowValues() {
|
||||
return [
|
||||
fileID,
|
||||
metadata?.toEncodedJson(),
|
||||
privMagicMetadata?.toEncodedJson(),
|
||||
pubMagicMetadata?.toEncodedJson(),
|
||||
info?.toEncodedJson(),
|
||||
];
|
||||
}
|
||||
|
||||
RemoteAsset toRemoteAsset() {
|
||||
return RemoteAsset.fromMetadata(
|
||||
id: fileID,
|
||||
ownerID: ownerID,
|
||||
thumbHeader: thumnailDecryptionHeader!,
|
||||
fileHeader: fileDecryptionHeader!,
|
||||
metadata: metadata!,
|
||||
privateMetadata: privMagicMetadata,
|
||||
publicMetadata: pubMagicMetadata,
|
||||
info: info,
|
||||
);
|
||||
}
|
||||
|
||||
String get title =>
|
||||
pubMagicMetadata?.data['editedName'] ?? metadata?.data['title'] ?? "";
|
||||
|
||||
String get nonEditedTitle {
|
||||
return metadata?.data['title'] ?? "";
|
||||
}
|
||||
|
||||
String? get localID => metadata?.data['localID'];
|
||||
|
||||
String? get deviceFolder => metadata?.data['deviceFolder'];
|
||||
|
||||
int get creationTime =>
|
||||
pubMagicMetadata?.data['editedTime'] ??
|
||||
metadata?.data['creationTime'] ??
|
||||
0;
|
||||
|
||||
int get modificationTime =>
|
||||
metadata?.data['modificationTime'] ?? creationTime;
|
||||
|
||||
// note: during remote to local sync, older live photo hash format from desktop
|
||||
// is already converted to the new format
|
||||
String? get hash => metadata?.data['hash'];
|
||||
|
||||
int get fileSize => info?.fileSize ?? -1;
|
||||
}
|
||||
|
||||
class DiffItem {
|
||||
final int collectionID;
|
||||
final bool isDeleted;
|
||||
final Uint8List? encFileKey;
|
||||
final Uint8List? encFileKeyNonce;
|
||||
final int updatedAt;
|
||||
final int? createdAt;
|
||||
final ApiFileItem fileItem;
|
||||
final TrashTime? trashTime;
|
||||
|
||||
DiffItem({
|
||||
required this.collectionID,
|
||||
required this.isDeleted,
|
||||
required this.updatedAt,
|
||||
required this.fileItem,
|
||||
this.createdAt,
|
||||
this.encFileKey,
|
||||
this.encFileKeyNonce,
|
||||
this.trashTime,
|
||||
});
|
||||
int get fileID => fileItem.fileID;
|
||||
|
||||
List<Object?> collectionFileRowValues() {
|
||||
return [
|
||||
collectionID,
|
||||
fileID,
|
||||
encFileKey,
|
||||
encFileKeyNonce,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
];
|
||||
}
|
||||
|
||||
List<Object?> trashRowValues() {
|
||||
return [
|
||||
fileID,
|
||||
fileItem.ownerID,
|
||||
collectionID,
|
||||
encFileKey,
|
||||
encFileKeyNonce,
|
||||
fileItem.fileDecryptionHeader,
|
||||
fileItem.thumnailDecryptionHeader,
|
||||
fileItem.metadata?.toEncodedJson(),
|
||||
fileItem.privMagicMetadata?.toEncodedJson(),
|
||||
fileItem.pubMagicMetadata?.toEncodedJson(),
|
||||
fileItem.info?.toEncodedJson(),
|
||||
trashTime!.createdAt,
|
||||
trashTime!.updatedAt,
|
||||
trashTime!.deleteBy,
|
||||
];
|
||||
}
|
||||
}
|
||||
21
mobile/apps/photos/lib/models/api/diff/trash_time.dart
Normal file
21
mobile/apps/photos/lib/models/api/diff/trash_time.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
class TrashTime {
|
||||
int createdAt;
|
||||
int updatedAt;
|
||||
int deleteBy;
|
||||
TrashTime({
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.deleteBy,
|
||||
});
|
||||
TrashTime.fromMap(Map<String, dynamic> map)
|
||||
: createdAt = map["createdAt"] as int,
|
||||
updatedAt = map["updatedAt"] as int,
|
||||
deleteBy = map["deleteBy"] as int;
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
"createdAt": createdAt,
|
||||
"updatedAt": updatedAt,
|
||||
"deleteBy": deleteBy,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import "dart:convert";
|
||||
import 'dart:core';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -5,6 +6,7 @@ import "package:photos/core/configuration.dart";
|
||||
import "package:photos/extensions/user_extension.dart";
|
||||
import "package:photos/models/api/collection/public_url.dart";
|
||||
import "package:photos/models/api/collection/user.dart";
|
||||
import "package:photos/models/collection/collection_old.dart";
|
||||
import "package:photos/models/metadata/collection_magic.dart";
|
||||
import "package:photos/models/metadata/common_keys.dart";
|
||||
|
||||
@@ -12,33 +14,18 @@ class Collection {
|
||||
final int id;
|
||||
final User owner;
|
||||
final String encryptedKey;
|
||||
final String? keyDecryptionNonce;
|
||||
@Deprecated("Use collectionName instead")
|
||||
// keyDecryptionNonce will be empty string for collections shared with the user
|
||||
final String keyDecryptionNonce;
|
||||
String? name;
|
||||
|
||||
// encryptedName & nameDecryptionNonce will be null for collections
|
||||
// created before we started encrypting collection name
|
||||
final String? encryptedName;
|
||||
final String? nameDecryptionNonce;
|
||||
final CollectionType type;
|
||||
final CollectionAttributes attributes;
|
||||
final List<User> sharees;
|
||||
final List<PublicURL> publicURLs;
|
||||
final int updationTime;
|
||||
final bool isDeleted;
|
||||
|
||||
// In early days before public launch, we used to store collection name
|
||||
// un-encrypted. decryptName will be value either decrypted value for
|
||||
// encryptedName or name itself.
|
||||
String? decryptedName;
|
||||
|
||||
// decryptedPath will be null for collections now owned by user, deleted
|
||||
// collections, && collections which don't have a path. The path is used
|
||||
// to map local on-device album on mobile to remote collection on ente.
|
||||
String? decryptedPath;
|
||||
String? mMdEncodedJson;
|
||||
String? mMdPubEncodedJson;
|
||||
String? sharedMmdJson;
|
||||
final String? localPath;
|
||||
String mMdEncodedJson;
|
||||
String mMdPubEncodedJson;
|
||||
String sharedMmdJson;
|
||||
int mMdVersion = 0;
|
||||
int mMbPubVersion = 0;
|
||||
int sharedMmdVersion = 0;
|
||||
@@ -47,14 +34,13 @@ class Collection {
|
||||
ShareeMagicMetadata? _sharedMmd;
|
||||
|
||||
CollectionMagicMetadata get magicMetadata =>
|
||||
_mmd ?? CollectionMagicMetadata.fromEncodedJson(mMdEncodedJson ?? '{}');
|
||||
_mmd ?? CollectionMagicMetadata.fromEncodedJson(mMdEncodedJson);
|
||||
|
||||
CollectionPubMagicMetadata get pubMagicMetadata =>
|
||||
_pubMmd ??
|
||||
CollectionPubMagicMetadata.fromEncodedJson(mMdPubEncodedJson ?? '{}');
|
||||
_pubMmd ?? CollectionPubMagicMetadata.fromEncodedJson(mMdPubEncodedJson);
|
||||
|
||||
ShareeMagicMetadata get sharedMagicMetadata =>
|
||||
_sharedMmd ?? ShareeMagicMetadata.fromEncodedJson(sharedMmdJson ?? '{}');
|
||||
_sharedMmd ?? ShareeMagicMetadata.fromEncodedJson(sharedMmdJson);
|
||||
|
||||
set magicMetadata(CollectionMagicMetadata? val) => _mmd = val;
|
||||
|
||||
@@ -69,32 +55,58 @@ class Collection {
|
||||
!isOwner(Configuration.instance.getUserID() ?? -1)) {
|
||||
return '${owner.nameOrEmail}\'s favorites';
|
||||
}
|
||||
return decryptedName ?? name ?? "Unnamed Album";
|
||||
return name ?? "Unnamed Album";
|
||||
}
|
||||
|
||||
// set the value for both name and decryptedName till we finish migration
|
||||
void setName(String newName) {
|
||||
// ignore: deprecated_member_use_from_same_package
|
||||
name = newName;
|
||||
decryptedName = newName;
|
||||
}
|
||||
|
||||
Collection(
|
||||
this.id,
|
||||
this.owner,
|
||||
this.encryptedKey,
|
||||
this.keyDecryptionNonce,
|
||||
this.name,
|
||||
this.encryptedName,
|
||||
this.nameDecryptionNonce,
|
||||
this.type,
|
||||
this.attributes,
|
||||
this.sharees,
|
||||
this.publicURLs,
|
||||
this.updationTime, {
|
||||
Collection({
|
||||
required this.id,
|
||||
required this.owner,
|
||||
required this.encryptedKey,
|
||||
required this.keyDecryptionNonce,
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.sharees,
|
||||
required this.publicURLs,
|
||||
required this.updationTime,
|
||||
required this.localPath,
|
||||
this.isDeleted = false,
|
||||
this.mMdEncodedJson = '{}',
|
||||
this.mMdPubEncodedJson = '{}',
|
||||
this.sharedMmdJson = '{}',
|
||||
this.mMdVersion = 0,
|
||||
this.mMbPubVersion = 0,
|
||||
this.sharedMmdVersion = 0,
|
||||
});
|
||||
|
||||
factory Collection.fromOldCollection(CollectionV2 collection) {
|
||||
return Collection(
|
||||
id: collection.id,
|
||||
owner: collection.owner,
|
||||
encryptedKey: collection.encryptedKey,
|
||||
// note: keyDecryptionNonce will be null in case of collections
|
||||
// shared with the user
|
||||
keyDecryptionNonce: collection.keyDecryptionNonce ?? '',
|
||||
name: collection.displayName,
|
||||
type: collection.type,
|
||||
sharees: collection.sharees,
|
||||
publicURLs: collection.publicURLs,
|
||||
updationTime: collection.updationTime,
|
||||
localPath: collection.decryptedPath,
|
||||
isDeleted: collection.isDeleted,
|
||||
mMbPubVersion: collection.mMbPubVersion,
|
||||
mMdPubEncodedJson: collection.mMdPubEncodedJson ?? '{}',
|
||||
mMdVersion: collection.mMdVersion,
|
||||
mMdEncodedJson: collection.mMdEncodedJson ?? '{}',
|
||||
sharedMmdJson: collection.sharedMmdJson ?? '{}',
|
||||
sharedMmdVersion: collection.sharedMmdVersion,
|
||||
);
|
||||
}
|
||||
|
||||
bool isArchived() {
|
||||
return mMdVersion > 0 && magicMetadata.visibility == archiveVisibility;
|
||||
}
|
||||
@@ -122,6 +134,15 @@ class Collection {
|
||||
return mMdVersion > 0 && (magicMetadata.visibility == hiddenVisibility);
|
||||
}
|
||||
|
||||
int get visibility {
|
||||
if (isHidden()) {
|
||||
return hiddenVisibility;
|
||||
} else if (isArchived() || hasShareeArchived()) {
|
||||
return archiveVisibility;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool isDefaultHidden() {
|
||||
return (magicMetadata.subType ?? 0) == subTypeDefaultHidden;
|
||||
}
|
||||
@@ -191,7 +212,10 @@ class Collection {
|
||||
// device album based on path. The path is nothing but the name of the device
|
||||
// album.
|
||||
bool canLinkToDevicePath(int userID) {
|
||||
return isOwner(userID) && !isDeleted && attributes.encryptedPath != null;
|
||||
return isOwner(userID) &&
|
||||
!isDeleted &&
|
||||
localPath != null &&
|
||||
localPath != '';
|
||||
}
|
||||
|
||||
void updateSharees(List<User> newSharees) {
|
||||
@@ -205,72 +229,90 @@ class Collection {
|
||||
String? encryptedKey,
|
||||
String? keyDecryptionNonce,
|
||||
String? name,
|
||||
String? encryptedName,
|
||||
String? nameDecryptionNonce,
|
||||
CollectionType? type,
|
||||
CollectionAttributes? attributes,
|
||||
List<User>? sharees,
|
||||
List<PublicURL>? publicURLs,
|
||||
int? updationTime,
|
||||
bool? isDeleted,
|
||||
String? localPath,
|
||||
String? mMdEncodedJson,
|
||||
int? mMdVersion,
|
||||
String? decryptedName,
|
||||
String? decryptedPath,
|
||||
String? mMdPubEncodedJson,
|
||||
int? mMbPubVersion,
|
||||
String? sharedMmdJson,
|
||||
int? sharedMmdVersion,
|
||||
}) {
|
||||
final Collection result = Collection(
|
||||
id ?? this.id,
|
||||
owner ?? this.owner,
|
||||
encryptedKey ?? this.encryptedKey,
|
||||
keyDecryptionNonce ?? this.keyDecryptionNonce,
|
||||
// ignore: deprecated_member_use_from_same_package
|
||||
name ?? this.name,
|
||||
encryptedName ?? this.encryptedName,
|
||||
nameDecryptionNonce ?? this.nameDecryptionNonce,
|
||||
type ?? this.type,
|
||||
attributes ?? this.attributes,
|
||||
sharees ?? this.sharees,
|
||||
publicURLs ?? this.publicURLs,
|
||||
updationTime ?? this.updationTime,
|
||||
id: id ?? this.id,
|
||||
owner: owner ?? this.owner,
|
||||
encryptedKey: encryptedKey ?? this.encryptedKey,
|
||||
keyDecryptionNonce: keyDecryptionNonce ?? this.keyDecryptionNonce,
|
||||
name: name ?? this.name,
|
||||
type: type ?? this.type,
|
||||
sharees: sharees ?? this.sharees,
|
||||
publicURLs: publicURLs ?? this.publicURLs,
|
||||
updationTime: updationTime ?? this.updationTime,
|
||||
localPath: localPath ?? this.localPath,
|
||||
isDeleted: isDeleted ?? this.isDeleted,
|
||||
mMdEncodedJson: mMdEncodedJson ?? this.mMdEncodedJson,
|
||||
mMdVersion: mMdVersion ?? this.mMdVersion,
|
||||
mMdPubEncodedJson: mMdPubEncodedJson ?? this.mMdPubEncodedJson,
|
||||
mMbPubVersion: mMbPubVersion ?? this.mMbPubVersion,
|
||||
sharedMmdJson: sharedMmdJson ?? this.sharedMmdJson,
|
||||
sharedMmdVersion: sharedMmdVersion ?? this.sharedMmdVersion,
|
||||
);
|
||||
result.mMdVersion = mMdVersion ?? this.mMdVersion;
|
||||
result.mMdEncodedJson = mMdEncodedJson ?? this.mMdEncodedJson;
|
||||
result.decryptedName = decryptedName ?? this.decryptedName;
|
||||
result.decryptedPath = decryptedPath ?? this.decryptedPath;
|
||||
result.mMbPubVersion = mMbPubVersion;
|
||||
result.mMdPubEncodedJson = mMdPubEncodedJson;
|
||||
result.sharedMmdVersion = sharedMmdVersion;
|
||||
result.sharedMmdJson = sharedMmdJson;
|
||||
return result;
|
||||
}
|
||||
|
||||
static fromMap(Map<String, dynamic>? map) {
|
||||
if (map == null) return null;
|
||||
final sharees = (map['sharees'] == null || map['sharees'].length == 0)
|
||||
? <User>[]
|
||||
: List<User>.from(map['sharees'].map((x) => User.fromMap(x)));
|
||||
final publicURLs =
|
||||
(map['publicURLs'] == null || map['publicURLs'].length == 0)
|
||||
? <PublicURL>[]
|
||||
: List<PublicURL>.from(
|
||||
map['publicURLs'].map((x) => PublicURL.fromMap(x)),
|
||||
);
|
||||
return Collection(
|
||||
map['id'],
|
||||
User.fromMap(map['owner']),
|
||||
map['encryptedKey'],
|
||||
map['keyDecryptionNonce'],
|
||||
map['name'],
|
||||
map['encryptedName'],
|
||||
map['nameDecryptionNonce'],
|
||||
typeFromString(map['type']),
|
||||
CollectionAttributes.fromMap(map['attributes']),
|
||||
sharees,
|
||||
publicURLs,
|
||||
map['updationTime'],
|
||||
isDeleted: map['isDeleted'] ?? false,
|
||||
static Collection fromRow(Map<String, dynamic> map) {
|
||||
final sharees = List<User>.from(
|
||||
(json.decode(map['sharees']) as List).map((x) => User.fromMap(x)),
|
||||
);
|
||||
final List<PublicURL> publicURLs = List<PublicURL>.from(
|
||||
(json.decode(map['public_urls']) as List)
|
||||
.map((x) => PublicURL.fromMap(x)),
|
||||
);
|
||||
return Collection(
|
||||
id: map['id'],
|
||||
owner: User.fromJson(map['owner']),
|
||||
encryptedKey: map['enc_key'],
|
||||
keyDecryptionNonce: map['enc_key_nonce'],
|
||||
name: map['name'],
|
||||
type: typeFromString(map['type']),
|
||||
sharees: sharees,
|
||||
publicURLs: publicURLs,
|
||||
updationTime: map['updation_time'],
|
||||
localPath: map['local_path'],
|
||||
isDeleted: (map['is_deleted'] as int) == 1,
|
||||
mMdEncodedJson: map['mmd_encoded_json'],
|
||||
mMdVersion: map['mmd_ver'],
|
||||
mMdPubEncodedJson: map['pub_mmd_encoded_json'],
|
||||
mMbPubVersion: map['pub_mmd_ver'],
|
||||
sharedMmdJson: map['shared_mmd_json'],
|
||||
sharedMmdVersion: map['shared_mmd_ver'],
|
||||
);
|
||||
}
|
||||
|
||||
List<Object?> rowValiues() {
|
||||
return [
|
||||
id,
|
||||
owner.toJson(),
|
||||
encryptedKey,
|
||||
keyDecryptionNonce,
|
||||
name,
|
||||
typeToString(type),
|
||||
localPath,
|
||||
isDeleted ? 1 : 0,
|
||||
updationTime,
|
||||
json.encode(sharees.map((x) => x.toMap()).toList()),
|
||||
json.encode(publicURLs.map((x) => x.toMap()).toList()),
|
||||
mMdEncodedJson,
|
||||
mMdVersion,
|
||||
mMdPubEncodedJson,
|
||||
mMbPubVersion,
|
||||
sharedMmdJson,
|
||||
sharedMmdVersion,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
252
mobile/apps/photos/lib/models/collection/collection_old.dart
Normal file
252
mobile/apps/photos/lib/models/collection/collection_old.dart
Normal file
@@ -0,0 +1,252 @@
|
||||
import "package:photos/models/api/collection/public_url.dart";
|
||||
import "package:photos/models/api/collection/user.dart";
|
||||
import "package:photos/models/collection/collection.dart";
|
||||
import "package:photos/models/metadata/collection_magic.dart";
|
||||
import "package:photos/models/metadata/common_keys.dart";
|
||||
|
||||
class CollectionV2 {
|
||||
final int id;
|
||||
final User owner;
|
||||
final String encryptedKey;
|
||||
final String? keyDecryptionNonce;
|
||||
@Deprecated("Use collectionName instead")
|
||||
String? name;
|
||||
|
||||
// encryptedName & nameDecryptionNonce will be null for collections
|
||||
// created before we started encrypting collection name
|
||||
final String? encryptedName;
|
||||
final String? nameDecryptionNonce;
|
||||
final CollectionType type;
|
||||
final CollectionAttributes attributes;
|
||||
final List<User> sharees;
|
||||
final List<PublicURL> publicURLs;
|
||||
final int updationTime;
|
||||
final bool isDeleted;
|
||||
|
||||
// In early days before public launch, we used to store collection name
|
||||
// un-encrypted. decryptName will be value either decrypted value for
|
||||
// encryptedName or name itself.
|
||||
String? decryptedName;
|
||||
|
||||
// decryptedPath will be null for collections now owned by user, deleted
|
||||
// collections, && collections which don't have a path. The path is used
|
||||
// to map local on-device album on mobile to remote collection on ente.
|
||||
String? decryptedPath;
|
||||
String? mMdEncodedJson;
|
||||
String? mMdPubEncodedJson;
|
||||
String? sharedMmdJson;
|
||||
int mMdVersion = 0;
|
||||
int mMbPubVersion = 0;
|
||||
int sharedMmdVersion = 0;
|
||||
CollectionMagicMetadata? _mmd;
|
||||
CollectionPubMagicMetadata? _pubMmd;
|
||||
ShareeMagicMetadata? _sharedMmd;
|
||||
|
||||
CollectionMagicMetadata get magicMetadata =>
|
||||
_mmd ?? CollectionMagicMetadata.fromEncodedJson(mMdEncodedJson ?? '{}');
|
||||
|
||||
CollectionPubMagicMetadata get pubMagicMetadata =>
|
||||
_pubMmd ??
|
||||
CollectionPubMagicMetadata.fromEncodedJson(mMdPubEncodedJson ?? '{}');
|
||||
|
||||
ShareeMagicMetadata get sharedMagicMetadata =>
|
||||
_sharedMmd ?? ShareeMagicMetadata.fromEncodedJson(sharedMmdJson ?? '{}');
|
||||
|
||||
set magicMetadata(CollectionMagicMetadata? val) => _mmd = val;
|
||||
|
||||
set pubMagicMetadata(CollectionPubMagicMetadata? val) => _pubMmd = val;
|
||||
|
||||
set sharedMagicMetadata(ShareeMagicMetadata? val) => _sharedMmd = val;
|
||||
|
||||
String get displayName => decryptedName ?? name ?? "Unnamed Album";
|
||||
|
||||
// set the value for both name and decryptedName till we finish migration
|
||||
void setName(String newName) {
|
||||
name = newName;
|
||||
decryptedName = newName;
|
||||
}
|
||||
|
||||
CollectionV2(
|
||||
this.id,
|
||||
this.owner,
|
||||
this.encryptedKey,
|
||||
this.keyDecryptionNonce,
|
||||
this.name,
|
||||
this.encryptedName,
|
||||
this.nameDecryptionNonce,
|
||||
this.type,
|
||||
this.attributes,
|
||||
this.sharees,
|
||||
this.publicURLs,
|
||||
this.updationTime, {
|
||||
this.isDeleted = false,
|
||||
});
|
||||
|
||||
bool isArchived() {
|
||||
return mMdVersion > 0 && magicMetadata.visibility == archiveVisibility;
|
||||
}
|
||||
|
||||
bool hasShareeArchived() {
|
||||
return sharedMmdVersion > 0 &&
|
||||
sharedMagicMetadata.visibility == archiveVisibility;
|
||||
}
|
||||
|
||||
// hasLink returns true if there's any link attached to the collection
|
||||
// including expired links
|
||||
bool get hasLink => publicURLs.isNotEmpty;
|
||||
|
||||
bool get hasCover => (pubMagicMetadata.coverID ?? 0) > 0;
|
||||
|
||||
// hasSharees returns true if the collection is shared with other ente users
|
||||
bool get hasSharees => sharees.isNotEmpty;
|
||||
|
||||
bool get isPinned => (magicMetadata.order ?? 0) != 0;
|
||||
|
||||
bool isHidden() {
|
||||
if (isDefaultHidden()) {
|
||||
return true;
|
||||
}
|
||||
return mMdVersion > 0 && (magicMetadata.visibility == hiddenVisibility);
|
||||
}
|
||||
|
||||
bool isDefaultHidden() {
|
||||
return (magicMetadata.subType ?? 0) == subTypeDefaultHidden;
|
||||
}
|
||||
|
||||
bool isQuickLinkCollection() {
|
||||
return (magicMetadata.subType ?? 0) == subTypeSharedFilesCollection &&
|
||||
!hasSharees;
|
||||
}
|
||||
|
||||
List<User> getSharees() {
|
||||
return sharees;
|
||||
}
|
||||
|
||||
bool isOwner(int userID) {
|
||||
return (owner.id ?? -100) == userID;
|
||||
}
|
||||
|
||||
bool isDownloadEnabledForPublicLink() {
|
||||
if (publicURLs.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
return publicURLs.first.enableDownload;
|
||||
}
|
||||
|
||||
bool isCollectEnabledForPublicLink() {
|
||||
if (publicURLs.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
return publicURLs.first.enableCollect;
|
||||
}
|
||||
|
||||
bool get isJoinEnabled {
|
||||
if (publicURLs.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
return publicURLs.first.enableJoin;
|
||||
}
|
||||
|
||||
CollectionParticipantRole getRole(int userID) {
|
||||
if (isOwner(userID)) {
|
||||
return CollectionParticipantRole.owner;
|
||||
}
|
||||
if (sharees.isEmpty) {
|
||||
return CollectionParticipantRole.unknown;
|
||||
}
|
||||
for (final User u in sharees) {
|
||||
if (u.id == userID) {
|
||||
if (u.isViewer) {
|
||||
return CollectionParticipantRole.viewer;
|
||||
} else if (u.isCollaborator) {
|
||||
return CollectionParticipantRole.collaborator;
|
||||
}
|
||||
}
|
||||
}
|
||||
return CollectionParticipantRole.unknown;
|
||||
}
|
||||
|
||||
// canLinkToDevicePath returns true if the collection can be linked to local
|
||||
// device album based on path. The path is nothing but the name of the device
|
||||
// album.
|
||||
bool canLinkToDevicePath(int userID) {
|
||||
return isOwner(userID) && !isDeleted && attributes.encryptedPath != null;
|
||||
}
|
||||
|
||||
void updateSharees(List<User> newSharees) {
|
||||
sharees.clear();
|
||||
sharees.addAll(newSharees);
|
||||
}
|
||||
|
||||
CollectionV2 copyWith({
|
||||
int? id,
|
||||
User? owner,
|
||||
String? encryptedKey,
|
||||
String? keyDecryptionNonce,
|
||||
String? name,
|
||||
String? encryptedName,
|
||||
String? nameDecryptionNonce,
|
||||
CollectionType? type,
|
||||
CollectionAttributes? attributes,
|
||||
List<User>? sharees,
|
||||
List<PublicURL>? publicURLs,
|
||||
int? updationTime,
|
||||
bool? isDeleted,
|
||||
String? mMdEncodedJson,
|
||||
int? mMdVersion,
|
||||
String? decryptedName,
|
||||
String? decryptedPath,
|
||||
}) {
|
||||
final CollectionV2 result = CollectionV2(
|
||||
id ?? this.id,
|
||||
owner ?? this.owner,
|
||||
encryptedKey ?? this.encryptedKey,
|
||||
keyDecryptionNonce ?? this.keyDecryptionNonce,
|
||||
name ?? this.name,
|
||||
encryptedName ?? this.encryptedName,
|
||||
nameDecryptionNonce ?? this.nameDecryptionNonce,
|
||||
type ?? this.type,
|
||||
attributes ?? this.attributes,
|
||||
sharees ?? this.sharees,
|
||||
publicURLs ?? this.publicURLs,
|
||||
updationTime ?? this.updationTime,
|
||||
isDeleted: isDeleted ?? this.isDeleted,
|
||||
);
|
||||
result.mMdVersion = mMdVersion ?? this.mMdVersion;
|
||||
result.mMdEncodedJson = mMdEncodedJson ?? this.mMdEncodedJson;
|
||||
result.decryptedName = decryptedName ?? this.decryptedName;
|
||||
result.decryptedPath = decryptedPath ?? this.decryptedPath;
|
||||
result.mMbPubVersion = mMbPubVersion;
|
||||
result.mMdPubEncodedJson = mMdPubEncodedJson;
|
||||
result.sharedMmdVersion = sharedMmdVersion;
|
||||
result.sharedMmdJson = sharedMmdJson;
|
||||
return result;
|
||||
}
|
||||
|
||||
static CollectionV2 fromMap(Map<String, dynamic> map) {
|
||||
final sharees = (map['sharees'] == null || map['sharees'].length == 0)
|
||||
? <User>[]
|
||||
: List<User>.from(map['sharees'].map((x) => User.fromMap(x)));
|
||||
final publicURLs =
|
||||
(map['publicURLs'] == null || map['publicURLs'].length == 0)
|
||||
? <PublicURL>[]
|
||||
: List<PublicURL>.from(
|
||||
map['publicURLs'].map((x) => PublicURL.fromMap(x)),
|
||||
);
|
||||
return CollectionV2(
|
||||
map['id'],
|
||||
User.fromMap(map['owner']),
|
||||
map['encryptedKey'],
|
||||
map['keyDecryptionNonce'],
|
||||
map['name'],
|
||||
map['encryptedName'],
|
||||
map['nameDecryptionNonce'],
|
||||
typeFromString(map['type']),
|
||||
CollectionAttributes.fromMap(map['attributes']),
|
||||
sharees,
|
||||
publicURLs,
|
||||
map['updationTime'],
|
||||
isDeleted: map['isDeleted'] ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import "package:photo_manager/photo_manager.dart";
|
||||
import 'package:photos/models/file/file.dart';
|
||||
import 'package:photos/models/upload_strategy.dart';
|
||||
|
||||
class DeviceCollection {
|
||||
final String id;
|
||||
final String name;
|
||||
AssetPathEntity assetPathEntity;
|
||||
final int count;
|
||||
final bool shouldBackup;
|
||||
UploadStrategy uploadStrategy;
|
||||
final String? coverId;
|
||||
int? collectionID;
|
||||
EnteFile? thumbnail;
|
||||
|
||||
@@ -15,10 +14,16 @@ class DeviceCollection {
|
||||
return collectionID != null && collectionID! != -1;
|
||||
}
|
||||
|
||||
String get name {
|
||||
return assetPathEntity.name;
|
||||
}
|
||||
|
||||
String get id {
|
||||
return assetPathEntity.id;
|
||||
}
|
||||
|
||||
DeviceCollection(
|
||||
this.id,
|
||||
this.name, {
|
||||
this.coverId,
|
||||
this.assetPathEntity, {
|
||||
this.count = 0,
|
||||
this.collectionID,
|
||||
this.thumbnail,
|
||||
|
||||
@@ -85,10 +85,10 @@ class DuplicateFiles {
|
||||
sortByCollectionName() {
|
||||
files.sort((first, second) {
|
||||
final firstName = collectionsService
|
||||
.getCollectionByID(first.collectionID!)!
|
||||
.getCollectionByID(first.cf!.collectionID)!
|
||||
.displayName;
|
||||
final secondName = collectionsService
|
||||
.getCollectionByID(second.collectionID!)!
|
||||
.getCollectionByID(second.cf!.collectionID!)!
|
||||
.displayName;
|
||||
return firstName.compareTo(secondName);
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import "package:photos/core/configuration.dart";
|
||||
import 'package:photos/models/file/extensions/r_asset_props.dart';
|
||||
import "package:photos/models/file/file.dart";
|
||||
import "package:photos/models/file/file_type.dart";
|
||||
import "package:photos/models/file/trash_file.dart";
|
||||
import "package:photos/services/collections_service.dart";
|
||||
|
||||
extension FilePropsExtn on EnteFile {
|
||||
bool get isLivePhoto => fileType == FileType.livePhoto;
|
||||
|
||||
bool get isMotionPhoto => (pubMagicMetadata?.mvi ?? 0) > 0;
|
||||
bool get isMotionPhoto => rAsset?.isMotionPhoto ?? false;
|
||||
|
||||
bool get isLiveOrMotionPhoto => isLivePhoto || isMotionPhoto;
|
||||
|
||||
@@ -23,8 +23,8 @@ extension FilePropsExtn on EnteFile {
|
||||
if (fileType != FileType.image) {
|
||||
return false;
|
||||
}
|
||||
if (pubMagicMetadata?.mediaType != null) {
|
||||
return (pubMagicMetadata!.mediaType! & 1) == 1;
|
||||
if (rAsset?.mediaType != null) {
|
||||
return (rAsset!.mediaType! & 1) == 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -42,22 +42,21 @@ extension FilePropsExtn on EnteFile {
|
||||
|
||||
bool get canEditMetaInfo => isUploaded && isOwner;
|
||||
|
||||
bool get isTrash => this is TrashFile;
|
||||
bool get isTrash => trashTime != null;
|
||||
|
||||
// Return true if the file was uploaded via collect photos workflow
|
||||
bool get isCollect => uploaderName != null;
|
||||
|
||||
String? get uploaderName => pubMagicMetadata?.uploaderName;
|
||||
String? get uploaderName => rAsset?.uploaderName;
|
||||
|
||||
bool get skipIndex => !isUploaded || fileType == FileType.other;
|
||||
|
||||
bool canReUpload(int userID) =>
|
||||
localID != null &&
|
||||
localID!.isNotEmpty &&
|
||||
lAsset != null &&
|
||||
cf != null &&
|
||||
isOwner &&
|
||||
collectionID != null &&
|
||||
(CollectionsService.instance
|
||||
.getCollectionByID(collectionID!)
|
||||
.getCollectionByID(cf!.collectionID)
|
||||
?.isOwner(userID) ??
|
||||
false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import "package:photos/models/file/remote/asset.dart";
|
||||
|
||||
extension RemoteAssetExtension on RemoteAsset {
|
||||
bool get isMotionPhoto {
|
||||
return (motionVideoIndex ?? 0) > 0;
|
||||
}
|
||||
}
|
||||
@@ -1,125 +1,102 @@
|
||||
import 'dart:io';
|
||||
import "dart:core";
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:photos/core/constants.dart';
|
||||
import "package:photos/models/api/diff/trash_time.dart";
|
||||
import 'package:photos/models/file/file_type.dart';
|
||||
import "package:photos/models/file/remote/asset.dart";
|
||||
import "package:photos/models/file/remote/collection_file.dart";
|
||||
import "package:photos/models/local/shared_asset.dart";
|
||||
import 'package:photos/models/location/location.dart';
|
||||
import "package:photos/models/metadata/file_magic.dart";
|
||||
import "package:photos/module/download/file_url.dart";
|
||||
import 'package:photos/utils/exif_util.dart';
|
||||
import 'package:photos/utils/file_uploader_util.dart';
|
||||
import "package:photos/utils/panorama_util.dart";
|
||||
import 'package:photos/utils/standalone/date_time.dart';
|
||||
import "package:photos/services/local/asset_entity.service.dart";
|
||||
|
||||
//Todo: files with no location data have lat and long set to 0.0. This should ideally be null.
|
||||
class EnteFile {
|
||||
static final _logger = Logger('EnteFile');
|
||||
AssetEntity? lAsset;
|
||||
RemoteAsset? rAsset;
|
||||
CollectionFile? cf;
|
||||
TrashTime? trashTime;
|
||||
SharedAsset? sharedAsset;
|
||||
|
||||
int? generatedID;
|
||||
int? uploadedFileID;
|
||||
int? ownerID;
|
||||
int? collectionID;
|
||||
String? localID;
|
||||
String? title;
|
||||
|
||||
String? deviceFolder;
|
||||
int? creationTime;
|
||||
int? modificationTime;
|
||||
int? updationTime;
|
||||
int? addedTime;
|
||||
Location? location;
|
||||
|
||||
late Location? location;
|
||||
late FileType fileType;
|
||||
int? fileSubType;
|
||||
int? duration;
|
||||
String? exif;
|
||||
String? hash;
|
||||
int? metadataVersion;
|
||||
String? encryptedKey;
|
||||
String? keyDecryptionNonce;
|
||||
String? fileDecryptionHeader;
|
||||
String? thumbnailDecryptionHeader;
|
||||
String? metadataDecryptionHeader;
|
||||
int? fileSize;
|
||||
|
||||
String? mMdEncodedJson;
|
||||
int mMdVersion = 0;
|
||||
MagicMetadata? _mmd;
|
||||
|
||||
MagicMetadata get magicMetadata =>
|
||||
_mmd ?? MagicMetadata.fromEncodedJson(mMdEncodedJson ?? '{}');
|
||||
|
||||
set magicMetadata(val) => _mmd = val;
|
||||
|
||||
// public magic metadata is shared if during file/album sharing
|
||||
String? pubMmdEncodedJson;
|
||||
int pubMmdVersion = 0;
|
||||
PubMagicMetadata? _pubMmd;
|
||||
|
||||
PubMagicMetadata? get pubMagicMetadata =>
|
||||
_pubMmd ?? PubMagicMetadata.fromEncodedJson(pubMmdEncodedJson ?? '{}');
|
||||
|
||||
set pubMagicMetadata(val) => _pubMmd = val;
|
||||
|
||||
// in Version 1, live photo hash is stored as zip's hash.
|
||||
// in V2: LivePhoto hash is stored as imgHash:vidHash
|
||||
static const kCurrentMetadataVersion = 2;
|
||||
|
||||
static final _logger = Logger('File');
|
||||
|
||||
EnteFile();
|
||||
|
||||
static Future<EnteFile> fromAsset(String pathName, AssetEntity asset) async {
|
||||
static Future<EnteFile> fromAsset(String pathName, AssetEntity lAsset) async {
|
||||
final EnteFile file = EnteFile();
|
||||
file.localID = asset.id;
|
||||
file.title = asset.title;
|
||||
file.lAsset = lAsset;
|
||||
file.deviceFolder = pathName;
|
||||
file.location =
|
||||
Location(latitude: asset.latitude, longitude: asset.longitude);
|
||||
file.fileType = fileTypeFromAsset(asset);
|
||||
file.creationTime = parseFileCreationTime(file.title, asset);
|
||||
file.modificationTime = asset.modifiedDateTime.microsecondsSinceEpoch;
|
||||
file.fileSubType = asset.subtype;
|
||||
file.metadataVersion = kCurrentMetadataVersion;
|
||||
Location(latitude: lAsset.latitude, longitude: lAsset.longitude);
|
||||
file.fileType = enteTypeFromAsset(lAsset);
|
||||
file.creationTime = AssetEntityService.estimateCreationTime(lAsset);
|
||||
file.modificationTime = lAsset.modifiedDateTime.microsecondsSinceEpoch;
|
||||
return file;
|
||||
}
|
||||
|
||||
static int parseFileCreationTime(String? fileTitle, AssetEntity asset) {
|
||||
int creationTime = asset.createDateTime.microsecondsSinceEpoch;
|
||||
final int modificationTime = asset.modifiedDateTime.microsecondsSinceEpoch;
|
||||
if (creationTime >= jan011981Time) {
|
||||
// assuming that fileSystem is returning correct creationTime.
|
||||
// During upload, this might get overridden with exif Creation time
|
||||
// When the assetModifiedTime is less than creationTime, than just use
|
||||
// that as creationTime. This is to handle cases where file might be
|
||||
// copied to the fileSystem from somewhere else See #https://superuser.com/a/1091147
|
||||
if (modificationTime >= jan011981Time &&
|
||||
modificationTime < creationTime) {
|
||||
_logger.info(
|
||||
'LocalID: ${asset.id} modification time is less than creation time. Using modification time as creation time',
|
||||
);
|
||||
creationTime = modificationTime;
|
||||
}
|
||||
return creationTime;
|
||||
} else {
|
||||
if (modificationTime >= jan011981Time) {
|
||||
creationTime = modificationTime;
|
||||
} else {
|
||||
creationTime = DateTime.now().toUtc().microsecondsSinceEpoch;
|
||||
}
|
||||
try {
|
||||
final parsedDateTime = parseDateTimeFromFileNameV2(
|
||||
basenameWithoutExtension(fileTitle ?? ""),
|
||||
);
|
||||
if (parsedDateTime != null) {
|
||||
creationTime = parsedDateTime.microsecondsSinceEpoch;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return creationTime;
|
||||
static EnteFile fromAssetSync(AssetEntity asset) {
|
||||
final EnteFile file = EnteFile();
|
||||
file.lAsset = asset;
|
||||
file.deviceFolder = asset.relativePath;
|
||||
file.location =
|
||||
Location(latitude: asset.latitude, longitude: asset.longitude);
|
||||
file.fileType = enteTypeFromAsset(asset);
|
||||
file.creationTime = asset.createDateTime.microsecondsSinceEpoch;
|
||||
file.modificationTime = asset.modifiedDateTime.microsecondsSinceEpoch;
|
||||
return file;
|
||||
}
|
||||
|
||||
static EnteFile fromRemoteAsset(
|
||||
RemoteAsset rAsset,
|
||||
CollectionFile collection, {
|
||||
AssetEntity? lAsset,
|
||||
}) {
|
||||
final EnteFile file = EnteFile();
|
||||
file.rAsset = rAsset;
|
||||
file.cf = collection;
|
||||
file.lAsset = lAsset;
|
||||
file.ownerID = rAsset.ownerID;
|
||||
// file.deviceFolder = rAsset.deviceFolder;
|
||||
file.location = rAsset.location;
|
||||
file.fileType = rAsset.fileType;
|
||||
file.creationTime = rAsset.creationTime;
|
||||
file.modificationTime = rAsset.modificationTime;
|
||||
return file;
|
||||
}
|
||||
|
||||
String? get localID => lAsset?.id ?? sharedAsset?.id;
|
||||
|
||||
int get remoteID {
|
||||
if (rAsset != null) {
|
||||
return rAsset!.id;
|
||||
} else {
|
||||
throw Exception("Remote ID is not set for the file");
|
||||
}
|
||||
}
|
||||
|
||||
String? get hash => rAsset?.hash;
|
||||
|
||||
int? get fileSubType => rAsset?.subType ?? lAsset?.subtype;
|
||||
|
||||
int? get uploadedFileID => rAsset?.id;
|
||||
|
||||
int? get durationInSec => rAsset?.durationInSec ?? lAsset?.duration;
|
||||
|
||||
String? get title => rAsset?.title ?? lAsset?.title;
|
||||
|
||||
int? get collectionID => cf?.collectionID;
|
||||
|
||||
Future<AssetEntity?> get getAsset {
|
||||
if (localID == null) {
|
||||
return Future.value(null);
|
||||
@@ -127,132 +104,23 @@ class EnteFile {
|
||||
return AssetEntity.fromId(localID!);
|
||||
}
|
||||
|
||||
void applyMetadata(Map<String, dynamic> metadata) {
|
||||
localID = metadata["localID"];
|
||||
title = metadata["title"];
|
||||
deviceFolder = metadata["deviceFolder"];
|
||||
creationTime = metadata["creationTime"] ?? 0;
|
||||
modificationTime = metadata["modificationTime"] ?? creationTime;
|
||||
final latitude = double.tryParse(metadata["latitude"].toString());
|
||||
final longitude = double.tryParse(metadata["longitude"].toString());
|
||||
if (latitude == null || longitude == null) {
|
||||
location = null;
|
||||
} else {
|
||||
location = Location(latitude: latitude, longitude: longitude);
|
||||
}
|
||||
fileType = getFileType(metadata["fileType"] ?? -1);
|
||||
fileSubType = metadata["subType"] ?? -1;
|
||||
duration = metadata["duration"] ?? 0;
|
||||
exif = metadata["exif"];
|
||||
hash = metadata["hash"];
|
||||
// handle past live photos upload from web client
|
||||
if (hash == null &&
|
||||
fileType == FileType.livePhoto &&
|
||||
metadata.containsKey('imageHash') &&
|
||||
metadata.containsKey('videoHash')) {
|
||||
// convert to imgHash:vidHash
|
||||
hash =
|
||||
'${metadata['imageHash']}$kLivePhotoHashSeparator${metadata['videoHash']}';
|
||||
}
|
||||
metadataVersion = metadata["version"] ?? 0;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getMetadataForUpload(
|
||||
MediaUploadData mediaUploadData,
|
||||
ParsedExifDateTime? exifTime,
|
||||
) async {
|
||||
final asset = await getAsset;
|
||||
// asset can be null for files shared to app
|
||||
if (asset != null) {
|
||||
fileSubType = asset.subtype;
|
||||
if (fileType == FileType.video) {
|
||||
duration = asset.duration;
|
||||
}
|
||||
}
|
||||
bool hasExifTime = false;
|
||||
if (exifTime != null && exifTime.time != null) {
|
||||
hasExifTime = true;
|
||||
creationTime = exifTime.time!.microsecondsSinceEpoch;
|
||||
}
|
||||
if (mediaUploadData.exifData != null) {
|
||||
mediaUploadData.isPanorama =
|
||||
checkPanoramaFromEXIF(null, mediaUploadData.exifData);
|
||||
}
|
||||
if (mediaUploadData.isPanorama != true &&
|
||||
fileType == FileType.image &&
|
||||
mediaUploadData.sourceFile != null) {
|
||||
try {
|
||||
final xmpData = await getXmp(mediaUploadData.sourceFile!);
|
||||
mediaUploadData.isPanorama = checkPanoramaFromXMP(xmpData);
|
||||
} catch (_) {}
|
||||
mediaUploadData.isPanorama ??= false;
|
||||
}
|
||||
|
||||
// Try to get the timestamp from fileName. In case of iOS, file names are
|
||||
// generic IMG_XXXX, so only parse it on Android devices
|
||||
if (!hasExifTime && Platform.isAndroid && title != null) {
|
||||
final timeFromFileName = parseDateTimeFromFileNameV2(title!);
|
||||
if (timeFromFileName != null) {
|
||||
// only use timeFromFileName if the existing creationTime and
|
||||
// timeFromFilename belongs to different date.
|
||||
// This is done because many times the fileTimeStamp will only give us
|
||||
// the date, not time value but the photo_manager's creation time will
|
||||
// contain the time.
|
||||
final bool useFileTimeStamp = creationTime == null ||
|
||||
!areFromSameDay(
|
||||
creationTime!,
|
||||
timeFromFileName.microsecondsSinceEpoch,
|
||||
);
|
||||
if (useFileTimeStamp) {
|
||||
creationTime = timeFromFileName.microsecondsSinceEpoch;
|
||||
}
|
||||
}
|
||||
}
|
||||
hash = mediaUploadData.hashData?.fileHash;
|
||||
return metadata;
|
||||
}
|
||||
|
||||
Map<String, dynamic> get metadata {
|
||||
final metadata = <String, dynamic>{};
|
||||
metadata["localID"] = isSharedMediaToAppSandbox ? null : localID;
|
||||
metadata["title"] = title;
|
||||
metadata["deviceFolder"] = deviceFolder;
|
||||
metadata["creationTime"] = creationTime;
|
||||
metadata["modificationTime"] = modificationTime;
|
||||
metadata["fileType"] = fileType.index;
|
||||
if (location != null &&
|
||||
location!.latitude != null &&
|
||||
location!.longitude != null) {
|
||||
metadata["latitude"] = location!.latitude;
|
||||
metadata["longitude"] = location!.longitude;
|
||||
}
|
||||
if (fileSubType != null) {
|
||||
metadata["subType"] = fileSubType;
|
||||
}
|
||||
if (duration != null) {
|
||||
metadata["duration"] = duration;
|
||||
}
|
||||
if (hash != null) {
|
||||
metadata["hash"] = hash;
|
||||
}
|
||||
if (metadataVersion != null) {
|
||||
metadata["version"] = metadataVersion;
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
String get downloadUrl =>
|
||||
FileUrl.getUrl(uploadedFileID!, FileUrlType.download);
|
||||
|
||||
String? get caption {
|
||||
return pubMagicMetadata?.caption;
|
||||
return rAsset?.caption;
|
||||
}
|
||||
|
||||
String? debugCaption;
|
||||
int? get fileSize {
|
||||
if (rAsset != null) {
|
||||
return rAsset!.fileSize;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String get displayName {
|
||||
if (pubMagicMetadata != null && pubMagicMetadata!.editedName != null) {
|
||||
return pubMagicMetadata!.editedName!;
|
||||
if (rAsset != null) {
|
||||
return rAsset!.title;
|
||||
}
|
||||
if (title == null && kDebugMode) _logger.severe('File title is null');
|
||||
return title ?? '';
|
||||
@@ -260,11 +128,17 @@ class EnteFile {
|
||||
|
||||
// return 0 if the height is not available
|
||||
int get height {
|
||||
return pubMagicMetadata?.h ?? 0;
|
||||
if (rAsset != null) {
|
||||
return rAsset!.height ?? 0;
|
||||
}
|
||||
return lAsset?.height ?? 0;
|
||||
}
|
||||
|
||||
int get width {
|
||||
return pubMagicMetadata?.w ?? 0;
|
||||
if (rAsset != null) {
|
||||
return rAsset!.width ?? 0;
|
||||
}
|
||||
return lAsset?.width ?? 0;
|
||||
}
|
||||
|
||||
bool get hasDimensions {
|
||||
@@ -273,15 +147,16 @@ class EnteFile {
|
||||
|
||||
// returns true if the file isn't available in the user's gallery
|
||||
bool get isRemoteFile {
|
||||
return localID == null && uploadedFileID != null;
|
||||
return localID == null && isUploaded;
|
||||
}
|
||||
|
||||
bool get isUploaded {
|
||||
return uploadedFileID != null;
|
||||
return rAsset != null;
|
||||
}
|
||||
|
||||
bool get isSharedMediaToAppSandbox {
|
||||
return localID != null && localID!.startsWith(sharedMediaIdentifier);
|
||||
// returns true if the file is only available in the app's sandbox
|
||||
bool get isInAppMedia {
|
||||
return sharedAsset != null;
|
||||
}
|
||||
|
||||
bool get hasLocation {
|
||||
@@ -293,7 +168,7 @@ class EnteFile {
|
||||
String toString() {
|
||||
return '''File(generatedID: $generatedID, localID: $localID, title: $title,
|
||||
type: $fileType, uploadedFileId: $uploadedFileID, modificationTime: $modificationTime,
|
||||
ownerID: $ownerID, collectionID: $collectionID, updationTime: $updationTime)''';
|
||||
ownerID: $ownerID, collectionID: $collectionID, updationTime: ${cf?.updatedAt})''';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -312,12 +187,7 @@ class EnteFile {
|
||||
}
|
||||
|
||||
String get tag {
|
||||
return "local_" +
|
||||
localID.toString() +
|
||||
":remote_" +
|
||||
uploadedFileID.toString() +
|
||||
":generated_" +
|
||||
generatedID.toString();
|
||||
return "local_$localID:remote_$uploadedFileID:generated_$generatedID";
|
||||
}
|
||||
|
||||
String cacheKey() {
|
||||
@@ -327,68 +197,27 @@ class EnteFile {
|
||||
|
||||
EnteFile copyWith({
|
||||
int? generatedID,
|
||||
int? uploadedFileID,
|
||||
int? ownerID,
|
||||
int? collectionID,
|
||||
String? localID,
|
||||
String? title,
|
||||
String? deviceFolder,
|
||||
int? creationTime,
|
||||
int? modificationTime,
|
||||
int? updationTime,
|
||||
int? addedTime,
|
||||
Location? location,
|
||||
FileType? fileType,
|
||||
int? fileSubType,
|
||||
int? duration,
|
||||
String? exif,
|
||||
String? hash,
|
||||
int? metadataVersion,
|
||||
String? encryptedKey,
|
||||
String? keyDecryptionNonce,
|
||||
String? fileDecryptionHeader,
|
||||
String? thumbnailDecryptionHeader,
|
||||
String? metadataDecryptionHeader,
|
||||
int? fileSize,
|
||||
String? mMdEncodedJson,
|
||||
int? mMdVersion,
|
||||
MagicMetadata? magicMetadata,
|
||||
String? pubMmdEncodedJson,
|
||||
int? pubMmdVersion,
|
||||
PubMagicMetadata? pubMagicMetadata,
|
||||
}) {
|
||||
return EnteFile()
|
||||
..lAsset = lAsset
|
||||
..rAsset = rAsset
|
||||
..cf = cf
|
||||
..generatedID = generatedID ?? this.generatedID
|
||||
..uploadedFileID = uploadedFileID ?? this.uploadedFileID
|
||||
..ownerID = ownerID ?? this.ownerID
|
||||
..collectionID = collectionID ?? this.collectionID
|
||||
..localID = localID ?? this.localID
|
||||
..title = title ?? this.title
|
||||
..deviceFolder = deviceFolder ?? this.deviceFolder
|
||||
..creationTime = creationTime ?? this.creationTime
|
||||
..modificationTime = modificationTime ?? this.modificationTime
|
||||
..updationTime = updationTime ?? this.updationTime
|
||||
..addedTime = addedTime ?? this.addedTime
|
||||
..location = location ?? this.location
|
||||
..fileType = fileType ?? this.fileType
|
||||
..fileSubType = fileSubType ?? this.fileSubType
|
||||
..duration = duration ?? this.duration
|
||||
..exif = exif ?? this.exif
|
||||
..hash = hash ?? this.hash
|
||||
..metadataVersion = metadataVersion ?? this.metadataVersion
|
||||
..encryptedKey = encryptedKey ?? this.encryptedKey
|
||||
..keyDecryptionNonce = keyDecryptionNonce ?? this.keyDecryptionNonce
|
||||
..fileDecryptionHeader = fileDecryptionHeader ?? this.fileDecryptionHeader
|
||||
..thumbnailDecryptionHeader =
|
||||
thumbnailDecryptionHeader ?? this.thumbnailDecryptionHeader
|
||||
..metadataDecryptionHeader =
|
||||
metadataDecryptionHeader ?? this.metadataDecryptionHeader
|
||||
..fileSize = fileSize ?? this.fileSize
|
||||
..mMdEncodedJson = mMdEncodedJson ?? this.mMdEncodedJson
|
||||
..mMdVersion = mMdVersion ?? this.mMdVersion
|
||||
..magicMetadata = magicMetadata ?? this.magicMetadata
|
||||
..pubMmdEncodedJson = pubMmdEncodedJson ?? this.pubMmdEncodedJson
|
||||
..pubMmdVersion = pubMmdVersion ?? this.pubMmdVersion
|
||||
..pubMagicMetadata = pubMagicMetadata ?? this.pubMagicMetadata;
|
||||
..fileType = fileType ?? this.fileType;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ enum FileType {
|
||||
image,
|
||||
video,
|
||||
livePhoto,
|
||||
other,
|
||||
other;
|
||||
|
||||
bool get isVideo => this == FileType.video;
|
||||
}
|
||||
|
||||
int getInt(FileType fileType) {
|
||||
@@ -35,7 +37,7 @@ FileType getFileType(int fileType) {
|
||||
}
|
||||
}
|
||||
|
||||
FileType fileTypeFromAsset(AssetEntity asset) {
|
||||
FileType enteTypeFromAsset(AssetEntity asset) {
|
||||
FileType type = FileType.image;
|
||||
switch (asset.type) {
|
||||
case AssetType.image:
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
class LocalAssetInfo {
|
||||
final String id;
|
||||
final String? hash;
|
||||
final String? title;
|
||||
final String? relativePath;
|
||||
final int state;
|
||||
|
||||
LocalAssetInfo({
|
||||
required this.id,
|
||||
this.hash,
|
||||
this.title,
|
||||
this.relativePath,
|
||||
required this.state,
|
||||
});
|
||||
|
||||
factory LocalAssetInfo.fromRow(Map<String, Object?> row) {
|
||||
return LocalAssetInfo(
|
||||
id: row['id'] as String,
|
||||
hash: row['hash'] as String?,
|
||||
title: row['title'] as String?,
|
||||
relativePath: row['relative_path'] as String?,
|
||||
state: row['scan_state'] as int,
|
||||
);
|
||||
}
|
||||
}
|
||||
190
mobile/apps/photos/lib/models/file/remote/asset.dart
Normal file
190
mobile/apps/photos/lib/models/file/remote/asset.dart
Normal file
@@ -0,0 +1,190 @@
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:photos/models/api/diff/diff.dart";
|
||||
import "package:photos/models/file/file_type.dart";
|
||||
import "package:photos/models/location/location.dart";
|
||||
import "package:photos/models/metadata/common_keys.dart";
|
||||
import "package:photos/models/metadata/file_magic.dart";
|
||||
|
||||
// Represents the remote asset stored in the database
|
||||
// Note: Ensure that the fields in this class matches the database schema
|
||||
// (remote_db -> files table). Keep the field order consistent with the schema.
|
||||
class RemoteAsset {
|
||||
final int id;
|
||||
final int ownerID;
|
||||
final Uint8List fileHeader;
|
||||
final Uint8List thumbHeader;
|
||||
final int creationTime;
|
||||
final int modificationTime;
|
||||
final int type;
|
||||
final int subType;
|
||||
final String title;
|
||||
final int? fileSize;
|
||||
final String? hash;
|
||||
|
||||
final int? visibility;
|
||||
final int? durationInSec;
|
||||
final Location? location;
|
||||
|
||||
final int? height;
|
||||
final int? width;
|
||||
final int? noThumb;
|
||||
final int? sv;
|
||||
final int? mediaType;
|
||||
final int? motionVideoIndex;
|
||||
|
||||
String? caption;
|
||||
final String? uploaderName;
|
||||
|
||||
RemoteAsset({
|
||||
required this.id,
|
||||
required this.ownerID,
|
||||
required this.thumbHeader,
|
||||
required this.fileHeader,
|
||||
required this.subType,
|
||||
required this.type,
|
||||
required this.creationTime,
|
||||
required this.modificationTime,
|
||||
required this.title,
|
||||
this.hash,
|
||||
this.visibility,
|
||||
this.durationInSec,
|
||||
this.location,
|
||||
this.height,
|
||||
this.width,
|
||||
this.sv,
|
||||
this.motionVideoIndex,
|
||||
this.noThumb,
|
||||
this.mediaType,
|
||||
this.uploaderName,
|
||||
this.fileSize,
|
||||
this.caption,
|
||||
});
|
||||
|
||||
// Factory constructor for creating from metadata (if needed for migration)
|
||||
factory RemoteAsset.fromMetadata({
|
||||
required int id,
|
||||
required int ownerID,
|
||||
required Uint8List thumbHeader,
|
||||
required Uint8List fileHeader,
|
||||
required Metadata metadata,
|
||||
Metadata? privateMetadata,
|
||||
Metadata? publicMetadata,
|
||||
Info? info,
|
||||
}) {
|
||||
return RemoteAsset(
|
||||
id: id,
|
||||
ownerID: ownerID,
|
||||
thumbHeader: thumbHeader,
|
||||
fileHeader: fileHeader,
|
||||
creationTime: publicMetadata?.data[editTimeKey] ??
|
||||
metadata.data['creationTime'] ??
|
||||
0,
|
||||
title: publicMetadata?.data[editNameKey] ?? metadata.data['title'] ?? "",
|
||||
modificationTime: metadata.data["modificationTime"] ??
|
||||
publicMetadata?.data[editTimeKey] ??
|
||||
metadata.data['creationTime'] ??
|
||||
0,
|
||||
hash: metadata.data['hash'],
|
||||
location: RemoteAsset.parseLocation(publicMetadata, metadata),
|
||||
durationInSec: metadata.data['duration'] ?? 0,
|
||||
fileSize: info?.fileSize,
|
||||
subType: metadata.data['subType'] ?? -1,
|
||||
type: metadata.data['fileType'] ?? -1,
|
||||
height: safeParseInt(publicMetadata?.data[heightKey], heightKey),
|
||||
width: safeParseInt(publicMetadata?.data[widthKey], widthKey),
|
||||
sv: publicMetadata?.data[streamVersionKey],
|
||||
motionVideoIndex: publicMetadata?.data[motionVideoIndexKey],
|
||||
noThumb: (publicMetadata?.data[noThumbKey] ??
|
||||
metadata.data["hasStaticThumbnail"] ??
|
||||
false)
|
||||
? 1
|
||||
: 0,
|
||||
caption: publicMetadata?.data[captionKey],
|
||||
mediaType: publicMetadata?.data[mediaTypeKey],
|
||||
uploaderName: publicMetadata?.data[uploaderNameKey],
|
||||
visibility: privateMetadata?.data[magicKeyVisibility],
|
||||
);
|
||||
}
|
||||
|
||||
RemoteAsset copyWith({
|
||||
int? id,
|
||||
int? ownerID,
|
||||
Uint8List? thumbHeader,
|
||||
Uint8List? fileHeader,
|
||||
int? subType,
|
||||
int? type,
|
||||
int? creationTime,
|
||||
int? modificationTime,
|
||||
String? title,
|
||||
String? hash,
|
||||
int? visibility,
|
||||
int? durationInSec,
|
||||
Location? location,
|
||||
int? height,
|
||||
int? width,
|
||||
int? sv,
|
||||
int? motionVideoIndex,
|
||||
int? noThumb,
|
||||
int? mediaType,
|
||||
String? deviceFolder,
|
||||
String? uploaderName,
|
||||
int? fileSize,
|
||||
String? caption,
|
||||
}) {
|
||||
return RemoteAsset(
|
||||
id: id ?? this.id,
|
||||
ownerID: ownerID ?? this.ownerID,
|
||||
thumbHeader: thumbHeader ?? this.thumbHeader,
|
||||
fileHeader: fileHeader ?? this.fileHeader,
|
||||
subType: subType ?? this.subType,
|
||||
type: type ?? this.type,
|
||||
creationTime: creationTime ?? this.creationTime,
|
||||
modificationTime: modificationTime ?? this.modificationTime,
|
||||
title: title ?? this.title,
|
||||
hash: hash ?? this.hash,
|
||||
visibility: visibility ?? this.visibility,
|
||||
durationInSec: durationInSec ?? this.durationInSec,
|
||||
location: location ?? this.location,
|
||||
height: height ?? this.height,
|
||||
width: width ?? this.width,
|
||||
sv: sv ?? this.sv,
|
||||
motionVideoIndex: motionVideoIndex ?? this.motionVideoIndex,
|
||||
noThumb: noThumb ?? this.noThumb,
|
||||
mediaType: mediaType ?? this.mediaType,
|
||||
uploaderName: uploaderName ?? this.uploaderName,
|
||||
fileSize: fileSize ?? this.fileSize,
|
||||
caption: caption ?? this.caption,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isArchived {
|
||||
return visibility == archiveVisibility;
|
||||
}
|
||||
|
||||
FileType get fileType {
|
||||
return getFileType(type);
|
||||
}
|
||||
|
||||
static Location? parseLocation(Metadata? publicMetadata, Metadata metadata) {
|
||||
if (publicMetadata?.data[latKey] != null) {
|
||||
return Location(
|
||||
latitude: publicMetadata!.data[latKey],
|
||||
longitude: publicMetadata!.data[longKey],
|
||||
);
|
||||
}
|
||||
if (metadata.data['latitude'] == null ||
|
||||
metadata.data['longitude'] == null) {
|
||||
return null;
|
||||
}
|
||||
final latitude = double.tryParse(metadata.data["latitude"].toString());
|
||||
final longitude = double.tryParse(metadata.data["longitude"].toString());
|
||||
if (latitude == null ||
|
||||
longitude == null ||
|
||||
(latitude == 0.0 && longitude == 0.0)) {
|
||||
return null;
|
||||
} else {
|
||||
return Location(latitude: latitude, longitude: longitude);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import "dart:typed_data";
|
||||
|
||||
class CollectionFile {
|
||||
final int collectionID;
|
||||
final int fileID;
|
||||
final Uint8List encFileKey;
|
||||
final Uint8List encFileKeyNonce;
|
||||
final int updatedAt;
|
||||
final int createdAt;
|
||||
|
||||
CollectionFile({
|
||||
required this.collectionID,
|
||||
required this.fileID,
|
||||
required this.encFileKey,
|
||||
required this.encFileKeyNonce,
|
||||
required this.updatedAt,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
CollectionFile.fromMap(Map<String, dynamic> map)
|
||||
: collectionID = map["collection_id"] as int,
|
||||
fileID = map["file_id"] as int,
|
||||
encFileKey = map["enc_key"] as Uint8List,
|
||||
encFileKeyNonce = map["enc_key_nonce"] as Uint8List,
|
||||
updatedAt = map["updated_at"] as int,
|
||||
createdAt = map["created_at"] as int;
|
||||
}
|
||||
57
mobile/apps/photos/lib/models/file/remote/rl_mapping.dart
Normal file
57
mobile/apps/photos/lib/models/file/remote/rl_mapping.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
class RLMapping {
|
||||
final int remoteUploadID;
|
||||
final String localID;
|
||||
final String? localCloudID;
|
||||
final MatchType mappingType;
|
||||
|
||||
RLMapping({
|
||||
required this.remoteUploadID,
|
||||
required this.localID,
|
||||
required this.localCloudID,
|
||||
required this.mappingType,
|
||||
});
|
||||
|
||||
List<Object?> get rowValues => [
|
||||
remoteUploadID,
|
||||
localID,
|
||||
localCloudID,
|
||||
mappingType.name,
|
||||
];
|
||||
}
|
||||
|
||||
enum MatchType {
|
||||
localID,
|
||||
cloudID,
|
||||
deviceUpload,
|
||||
deviceHashMatched,
|
||||
}
|
||||
|
||||
extension MappingTypeExtension on MatchType {
|
||||
String get name {
|
||||
switch (this) {
|
||||
case MatchType.localID:
|
||||
return "localID";
|
||||
case MatchType.cloudID:
|
||||
return "cloudID";
|
||||
case MatchType.deviceUpload:
|
||||
return "deviceUpload";
|
||||
case MatchType.deviceHashMatched:
|
||||
return "deviceHashMatched";
|
||||
}
|
||||
}
|
||||
|
||||
static MatchType fromName(String name) {
|
||||
switch (name) {
|
||||
case "localID":
|
||||
return MatchType.localID;
|
||||
case "cloudID":
|
||||
return MatchType.cloudID;
|
||||
case "deviceUpload":
|
||||
return MatchType.deviceUpload;
|
||||
case "deviceHashMatched":
|
||||
return MatchType.deviceHashMatched;
|
||||
default:
|
||||
throw Exception("Unknown mapping type: $name");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import 'package:photos/models/file/file.dart';
|
||||
|
||||
class TrashFile extends EnteFile {
|
||||
// time when file was put in the trash for first time
|
||||
late int createdAt;
|
||||
|
||||
// for non-deleted trash items, updateAt is usually equal to the latest time
|
||||
// when the file was moved to trash
|
||||
late int updateAt;
|
||||
|
||||
// time after which will will be deleted from trash & user's storage usage
|
||||
// will go down
|
||||
late int deleteBy;
|
||||
}
|
||||
@@ -21,7 +21,7 @@ class FilesSplit {
|
||||
ownedByOtherUsers = [],
|
||||
pendingUploads = [];
|
||||
for (var f in files) {
|
||||
if (f.ownerID == null || f.uploadedFileID == null) {
|
||||
if (f.ownerID == null || !f.isUploaded) {
|
||||
pendingUploads.add(f);
|
||||
} else if (f.ownerID == currentUserID) {
|
||||
ownedByCurrentUser.add(f);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:photos/models/file/trash_file.dart';
|
||||
import "package:photos/models/api/diff/diff.dart";
|
||||
|
||||
const kIgnoreReasonTrash = "trash";
|
||||
|
||||
@@ -10,21 +10,21 @@ class IgnoredFile {
|
||||
|
||||
IgnoredFile(this.localID, this.title, this.deviceFolder, this.reason);
|
||||
|
||||
static fromTrashItem(TrashFile? trashFile) {
|
||||
if (trashFile == null) return null;
|
||||
if (trashFile.localID == null ||
|
||||
trashFile.localID!.isEmpty ||
|
||||
trashFile.title == null ||
|
||||
trashFile.title!.isEmpty ||
|
||||
trashFile.deviceFolder == null ||
|
||||
trashFile.deviceFolder!.isEmpty) {
|
||||
static fromTrashItem(DiffItem? item) {
|
||||
if (item == null) return null;
|
||||
final fileItem = item.fileItem;
|
||||
if (fileItem.localID == null ||
|
||||
fileItem.localID!.isEmpty ||
|
||||
fileItem.nonEditedTitle.isEmpty ||
|
||||
fileItem.deviceFolder == null ||
|
||||
fileItem.deviceFolder!.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return IgnoredFile(
|
||||
trashFile.localID,
|
||||
trashFile.title,
|
||||
trashFile.deviceFolder,
|
||||
fileItem.localID,
|
||||
fileItem.nonEditedTitle,
|
||||
fileItem.deviceFolder,
|
||||
kIgnoreReasonTrash,
|
||||
);
|
||||
}
|
||||
|
||||
57
mobile/apps/photos/lib/models/local/asset.dart
Normal file
57
mobile/apps/photos/lib/models/local/asset.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import "package:photos/models/file/file_type.dart";
|
||||
import "package:photos/models/location/location.dart";
|
||||
|
||||
class LocalAsset {
|
||||
/// The ID of the asset.
|
||||
/// AssetEntity.id
|
||||
final String id;
|
||||
|
||||
final FileType type;
|
||||
|
||||
final int subType;
|
||||
|
||||
final int width;
|
||||
final int height;
|
||||
final int durationInSec;
|
||||
final int orientation;
|
||||
|
||||
/// Whether the asset is favorite on the device.
|
||||
/// See also:
|
||||
/// * [AssetEntity.isFavorite]
|
||||
final bool isFavorite;
|
||||
|
||||
final String title;
|
||||
|
||||
/// See [AssetEntity.relativePath]
|
||||
final String? relativePath;
|
||||
|
||||
final int createdAt;
|
||||
final int modifiedAt;
|
||||
// /// See [AssetEntity.relativePath]
|
||||
final String? mimeType;
|
||||
|
||||
final Location? location;
|
||||
final int scanState;
|
||||
final String? hash;
|
||||
final int? size;
|
||||
|
||||
LocalAsset({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.subType,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.durationInSec,
|
||||
required this.orientation,
|
||||
required this.isFavorite,
|
||||
required this.title,
|
||||
this.relativePath,
|
||||
required this.createdAt,
|
||||
required this.modifiedAt,
|
||||
this.mimeType,
|
||||
this.location,
|
||||
required this.scanState,
|
||||
this.hash,
|
||||
this.size,
|
||||
});
|
||||
}
|
||||
21
mobile/apps/photos/lib/models/local/asset_upload_queue.dart
Normal file
21
mobile/apps/photos/lib/models/local/asset_upload_queue.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import "package:photos/models/file/file_type.dart";
|
||||
|
||||
class AssetUploadQueue {
|
||||
final String id;
|
||||
final int destCollectionId;
|
||||
final String? pathId;
|
||||
final int ownerId;
|
||||
final bool manual;
|
||||
late FileType? fileType;
|
||||
late int? createdAt;
|
||||
|
||||
AssetUploadQueue({
|
||||
required this.id,
|
||||
required this.destCollectionId,
|
||||
required this.pathId,
|
||||
required this.ownerId,
|
||||
this.manual = false,
|
||||
this.fileType,
|
||||
this.createdAt,
|
||||
});
|
||||
}
|
||||
45
mobile/apps/photos/lib/models/local/local_metadata.dart
Normal file
45
mobile/apps/photos/lib/models/local/local_metadata.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
import "package:photos/models/location/location.dart";
|
||||
|
||||
class MetadataResult {
|
||||
final DroidMetadata? droid;
|
||||
final IOSMetadata? iOSMetadata;
|
||||
final int processedState;
|
||||
|
||||
MetadataResult({
|
||||
this.droid,
|
||||
this.iOSMetadata,
|
||||
required this.processedState,
|
||||
});
|
||||
}
|
||||
|
||||
class DroidMetadata {
|
||||
int creationTime;
|
||||
int modificationTime;
|
||||
String hash;
|
||||
int size;
|
||||
Location? location;
|
||||
bool? isPanorama;
|
||||
int? mviIndex;
|
||||
DroidMetadata({
|
||||
required this.hash,
|
||||
required this.size,
|
||||
required this.creationTime,
|
||||
required this.modificationTime,
|
||||
this.mviIndex,
|
||||
this.location,
|
||||
this.isPanorama,
|
||||
});
|
||||
}
|
||||
|
||||
class IOSMetadata {
|
||||
// https://developer.apple.com/documentation/photos/phcloudidentifier
|
||||
// Bulk mapping from local to cloud identifiers & vice versa
|
||||
String? cloudIdentifier;
|
||||
// https://developer.apple.com/documentation/photos/phassetsourcetype
|
||||
int? sourceType;
|
||||
bool? hasAdjustments;
|
||||
String? adjustmentFormatIdentifier;
|
||||
bool? representsBurst;
|
||||
String? burstIdentifier;
|
||||
int? burstSelectionTypes;
|
||||
}
|
||||
20
mobile/apps/photos/lib/models/local/path_config.dart
Normal file
20
mobile/apps/photos/lib/models/local/path_config.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import "package:photos/models/upload_strategy.dart";
|
||||
|
||||
class PathConfig {
|
||||
final String pathID;
|
||||
final int ownerId;
|
||||
// the target collection ID where the assets in this path will be uploaded
|
||||
// if null, the client will try to map to existing collection based on the path
|
||||
// or create a new collection if no mapping exists
|
||||
final int? destCollectionID;
|
||||
final bool shouldBackup;
|
||||
final UploadStrategy uploadStrategy;
|
||||
|
||||
PathConfig(
|
||||
this.pathID,
|
||||
this.ownerId,
|
||||
this.destCollectionID,
|
||||
this.shouldBackup,
|
||||
this.uploadStrategy,
|
||||
);
|
||||
}
|
||||
51
mobile/apps/photos/lib/models/local/shared_asset.dart
Normal file
51
mobile/apps/photos/lib/models/local/shared_asset.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
import "package:photos/models/file/file_type.dart";
|
||||
|
||||
class SharedAsset {
|
||||
final String id;
|
||||
final String name;
|
||||
final FileType type;
|
||||
final int creationTime;
|
||||
final int durationInSeconds;
|
||||
final int destCollectionID;
|
||||
final int ownerID;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
|
||||
SharedAsset({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.creationTime,
|
||||
required this.durationInSeconds,
|
||||
required this.destCollectionID,
|
||||
required this.ownerID,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
});
|
||||
|
||||
List<Object?> get rowProps => [
|
||||
id,
|
||||
name,
|
||||
getInt(type),
|
||||
creationTime,
|
||||
durationInSeconds,
|
||||
destCollectionID,
|
||||
ownerID,
|
||||
latitude,
|
||||
longitude,
|
||||
];
|
||||
|
||||
factory SharedAsset.fromRow(Map<String, dynamic> map) {
|
||||
return SharedAsset(
|
||||
id: map['id'] as String,
|
||||
name: map['name'] as String,
|
||||
type: getFileType(['type'] as int),
|
||||
creationTime: map['creation_time'] as int,
|
||||
durationInSeconds: map['duration_in_seconds'] as int,
|
||||
destCollectionID: map['dest_collection_id'] as int,
|
||||
ownerID: map['owner_id'] as int,
|
||||
latitude: map['latitude'] as double?,
|
||||
longitude: map['longitude'] as double?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -144,8 +144,8 @@ class ToShowMemory {
|
||||
return ToShowMemory(
|
||||
memory.title,
|
||||
memory.memories
|
||||
.where((m) => m.file.uploadedFileID != null)
|
||||
.map((m) => m.file.uploadedFileID!)
|
||||
.where((m) => m.file.isUploaded)
|
||||
.map((m) => m.file.remoteID)
|
||||
.toList(),
|
||||
memory.type,
|
||||
memory.firstDateToShow,
|
||||
|
||||
@@ -1,44 +1,22 @@
|
||||
import "dart:convert";
|
||||
|
||||
import "package:flutter/cupertino.dart";
|
||||
import 'package:photos/models/metadata/common_keys.dart';
|
||||
|
||||
const editTimeKey = 'editedTime';
|
||||
const editNameKey = 'editedName';
|
||||
const latKey = "lat";
|
||||
const longKey = "long";
|
||||
const noThumbKey = "noThumb";
|
||||
const captionKey = "caption";
|
||||
const uploaderNameKey = "uploaderName";
|
||||
const widthKey = 'w';
|
||||
const heightKey = 'h';
|
||||
const streamVersionKey = 'sv';
|
||||
const mediaTypeKey = 'mediaType';
|
||||
const latKey = "lat";
|
||||
const longKey = "long";
|
||||
const motionVideoIndexKey = "mvi";
|
||||
const noThumbKey = "noThumb";
|
||||
const dateTimeKey = 'dateTime';
|
||||
const offsetTimeKey = 'offsetTime';
|
||||
|
||||
class MagicMetadata {
|
||||
// 0 -> visible
|
||||
// 1 -> archived
|
||||
// 2 -> hidden etc?
|
||||
int visibility;
|
||||
|
||||
MagicMetadata({required this.visibility});
|
||||
|
||||
factory MagicMetadata.fromEncodedJson(String encodedJson) =>
|
||||
MagicMetadata.fromJson(jsonDecode(encodedJson));
|
||||
|
||||
factory MagicMetadata.fromJson(dynamic json) => MagicMetadata.fromMap(json);
|
||||
|
||||
static fromMap(Map<String, dynamic>? map) {
|
||||
if (map == null) return null;
|
||||
return MagicMetadata(
|
||||
visibility: map[magicKeyVisibility] ?? visibleVisibility,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PubMagicMetadata {
|
||||
int? editedTime;
|
||||
String? editedName;
|
||||
@@ -116,12 +94,12 @@ class PubMagicMetadata {
|
||||
sv: safeParseInt(map[streamVersionKey], streamVersionKey),
|
||||
);
|
||||
}
|
||||
|
||||
static int? safeParseInt(dynamic value, String key) {
|
||||
if (value == null) return null;
|
||||
if (value is int) return value;
|
||||
debugPrint("PubMagicMetadata key: $key Unexpected value: $value");
|
||||
if (value is String) return int.tryParse(value);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
int? safeParseInt(dynamic value, String key) {
|
||||
if (value == null) return null;
|
||||
if (value is int) return value;
|
||||
debugPrint("PubMagicMetadata key: $key Unexpected value: $value");
|
||||
if (value is String) return int.tryParse(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import "dart:convert";
|
||||
|
||||
import "package:photos/models/ml/ml_versions.dart";
|
||||
|
||||
class ClipEmbedding {
|
||||
final int fileID;
|
||||
class ClipEmbedding<T> {
|
||||
final T fileID;
|
||||
final List<double> embedding;
|
||||
int version;
|
||||
|
||||
@@ -15,7 +15,7 @@ class ClipEmbedding {
|
||||
required this.version,
|
||||
});
|
||||
|
||||
factory ClipEmbedding.empty(int fileID) {
|
||||
factory ClipEmbedding.empty(T fileID) {
|
||||
return ClipEmbedding(
|
||||
fileID: fileID,
|
||||
embedding: <double>[],
|
||||
|
||||
@@ -16,7 +16,7 @@ class FileInfo {
|
||||
});
|
||||
}
|
||||
|
||||
class Face {
|
||||
class Face<T> {
|
||||
final String faceID;
|
||||
final List<double> embedding;
|
||||
Detection detection;
|
||||
@@ -26,7 +26,7 @@ class Face {
|
||||
///#region Local DB fields
|
||||
// This is not stored on the server, using it for local DB row
|
||||
FileInfo? fileInfo;
|
||||
final int fileID;
|
||||
final T fileID;
|
||||
|
||||
///#endregion
|
||||
|
||||
@@ -48,7 +48,7 @@ class Face {
|
||||
|
||||
factory Face.fromFaceResult(
|
||||
FaceResult faceResult,
|
||||
int fileID,
|
||||
T fileID,
|
||||
Dimensions decodedDimensions,
|
||||
) {
|
||||
final detection = Detection(
|
||||
@@ -81,7 +81,7 @@ class Face {
|
||||
);
|
||||
}
|
||||
|
||||
factory Face.empty(int fileID, {bool error = false}) {
|
||||
static Face<T> empty<T>(T fileID, {bool error = false}) {
|
||||
return Face(
|
||||
"${fileID}_0_0_0_0",
|
||||
fileID,
|
||||
@@ -92,9 +92,9 @@ class Face {
|
||||
);
|
||||
}
|
||||
|
||||
factory Face.fromJson(Map<String, dynamic> json) {
|
||||
static Face<T> fromJson<T>(Map<String, dynamic> json) {
|
||||
final String faceID = json['faceID'] as String;
|
||||
final int fileID = getFileIdFromFaceId<int>(faceID);
|
||||
final T fileID = getFileIdFromFaceId<T>(faceID);
|
||||
return Face(
|
||||
faceID,
|
||||
fileID,
|
||||
|
||||
@@ -3,9 +3,9 @@ import "package:photos/services/machine_learning/face_ml/face_filtering/face_fil
|
||||
import "package:photos/services/machine_learning/ml_result.dart";
|
||||
import "package:photos/utils/standalone/parse.dart";
|
||||
|
||||
class FaceWithoutEmbedding {
|
||||
class FaceWithoutEmbedding<T> {
|
||||
final String faceID;
|
||||
final int fileID;
|
||||
final T fileID;
|
||||
Detection detection;
|
||||
final double score;
|
||||
final double blur;
|
||||
@@ -24,10 +24,10 @@ class FaceWithoutEmbedding {
|
||||
this.blur,
|
||||
);
|
||||
|
||||
factory FaceWithoutEmbedding.fromJson(Map<String, dynamic> json) {
|
||||
static FaceWithoutEmbedding fromJson<T>(Map<String, dynamic> json) {
|
||||
final String faceID = json['faceID'] as String;
|
||||
final int fileID = getFileIdFromFaceId<int>(faceID);
|
||||
return FaceWithoutEmbedding(
|
||||
final T fileID = getFileIdFromFaceId<T>(faceID);
|
||||
return FaceWithoutEmbedding<T>(
|
||||
faceID,
|
||||
fileID,
|
||||
parseIntOrDoubleAsDouble(json['score'])!,
|
||||
|
||||
@@ -223,6 +223,7 @@ extension SectionTypeExtensions on SectionType {
|
||||
return SearchService.instance.getAllFace(limit);
|
||||
case SectionType.magic:
|
||||
return SearchService.instance.getMagicSectionResults(context!);
|
||||
|
||||
case SectionType.location:
|
||||
return SearchService.instance.getAllLocationTags(limit);
|
||||
|
||||
|
||||
@@ -69,12 +69,11 @@ class SelectedFiles extends ChangeNotifier {
|
||||
}
|
||||
|
||||
bool _isMatch(EnteFile first, EnteFile second) {
|
||||
if (first.generatedID != null && second.generatedID != null) {
|
||||
if (first.generatedID == second.generatedID) {
|
||||
return true;
|
||||
}
|
||||
} else if (first.uploadedFileID != null && second.uploadedFileID != null) {
|
||||
return first.uploadedFileID == second.uploadedFileID;
|
||||
|
||||
if (first.isUploaded && second.isUploaded) {
|
||||
return first.remoteID == second.remoteID;
|
||||
} else if (first.lAsset != null && second.lAsset != null) {
|
||||
return first.lAsset!.id == second.lAsset!.id;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
54
mobile/apps/photos/lib/module/upload/model/candidates.dart
Normal file
54
mobile/apps/photos/lib/module/upload/model/candidates.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import "package:photos/models/file/file.dart";
|
||||
import "package:photos/models/local/asset_upload_queue.dart";
|
||||
|
||||
class AssetUploadCandidates {
|
||||
// own presents files that needs to be uploaded to the user's own collection
|
||||
// shared presents files that needs to be uploaded to the user's shared collection
|
||||
// unknwon presents files that needs to be uploaded to a collection that is not found
|
||||
List<(AssetUploadQueue, EnteFile)> own = [];
|
||||
List<(AssetUploadQueue, EnteFile)> shared = [];
|
||||
List<(AssetUploadQueue, EnteFile)> unknwon = [];
|
||||
int ignored = 0;
|
||||
int skippedVideos = 0;
|
||||
int forcedVideos = 0;
|
||||
bool includeVideos = false;
|
||||
AssetUploadCandidates({
|
||||
this.own = const [],
|
||||
this.shared = const [],
|
||||
this.unknwon = const [],
|
||||
this.ignored = 0,
|
||||
this.skippedVideos = 0,
|
||||
this.forcedVideos = 0,
|
||||
this.includeVideos = false,
|
||||
});
|
||||
|
||||
/// debugPrint entries in a readable format, only include counts for list, and non-default values
|
||||
/// for other fields
|
||||
/// This is used for debugging purposes.
|
||||
/// @return a string representation of the AssetUploadCandidates
|
||||
@override
|
||||
String toString() {
|
||||
final StringBuffer sb = StringBuffer();
|
||||
sb.writeln("AssetUploadCandidates:");
|
||||
if (own.isNotEmpty) {
|
||||
sb.writeln(" ownedCollection: ${own.length}");
|
||||
}
|
||||
if (shared.isNotEmpty) {
|
||||
sb.writeln(" sharedCollection: ${shared.length}");
|
||||
}
|
||||
if (unknwon.isNotEmpty) {
|
||||
sb.writeln(" missingCollection: ${unknwon.length}");
|
||||
}
|
||||
if (ignored > 0) {
|
||||
sb.writeln(" ignored: $ignored");
|
||||
}
|
||||
if (skippedVideos > 0) {
|
||||
sb.writeln(" skippedVideos: $skippedVideos");
|
||||
}
|
||||
if (forcedVideos > 0) {
|
||||
sb.writeln(" forcedVideos: $forcedVideos");
|
||||
}
|
||||
sb.writeln(" includeVideos: $includeVideos");
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
29
mobile/apps/photos/lib/module/upload/model/item.dart
Normal file
29
mobile/apps/photos/lib/module/upload/model/item.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
import "dart:async";
|
||||
|
||||
import "package:photos/models/file/file.dart";
|
||||
import "package:photos/models/local/asset_upload_queue.dart";
|
||||
|
||||
class FileUploadItem {
|
||||
final EnteFile file;
|
||||
final int collectionID;
|
||||
final Completer<EnteFile> completer;
|
||||
final AssetUploadQueue? assetQueue;
|
||||
UploadStatus status;
|
||||
|
||||
FileUploadItem(
|
||||
this.file,
|
||||
this.collectionID,
|
||||
this.completer, {
|
||||
this.assetQueue,
|
||||
this.status = UploadStatus.notStarted,
|
||||
});
|
||||
|
||||
String get lockKey => assetQueue?.id ?? file.localID!;
|
||||
}
|
||||
|
||||
enum UploadStatus { notStarted, inProgress, inBackground, completed }
|
||||
|
||||
enum ProcessType {
|
||||
background,
|
||||
foreground,
|
||||
}
|
||||
61
mobile/apps/photos/lib/module/upload/model/media.dart
Normal file
61
mobile/apps/photos/lib/module/upload/model/media.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
import "dart:io";
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:photo_manager/photo_manager.dart";
|
||||
import "package:photos/models/file/file_type.dart";
|
||||
import "package:photos/models/local/shared_asset.dart";
|
||||
|
||||
// UploadMedia holds information about the actual media that's being uploaded.
|
||||
// Apart from hash, this doesn't contain any metadata that will be reported to the server
|
||||
// as part of the upload request. The metadata is handled separately in the UploadData class.
|
||||
class UploadMedia {
|
||||
final File uploadFile;
|
||||
final FileType fileType;
|
||||
final Uint8List? thumbnail;
|
||||
final bool isDeleted;
|
||||
final String hash;
|
||||
final String? livePhotoImage;
|
||||
final String? livePhotoVideo;
|
||||
final AssetEntity? localAsset;
|
||||
final SharedAsset? sharedAsset;
|
||||
|
||||
UploadMedia(
|
||||
this.uploadFile,
|
||||
this.thumbnail,
|
||||
this.isDeleted,
|
||||
this.fileType,
|
||||
this.hash, {
|
||||
this.livePhotoVideo,
|
||||
this.livePhotoImage,
|
||||
this.localAsset,
|
||||
this.sharedAsset,
|
||||
}) : assert(
|
||||
(localAsset != null && sharedAsset == null) ||
|
||||
(localAsset == null && sharedAsset != null),
|
||||
'Either localAsset or sharedAsset must be present, but not both',
|
||||
),
|
||||
assert(
|
||||
fileType == FileType.livePhoto
|
||||
? (livePhotoImage != null && livePhotoVideo != null)
|
||||
: (livePhotoImage == null && livePhotoVideo == null),
|
||||
'For live photos, both livePhotoImage and livePhotoVideo must be present. For other file types, both must be null',
|
||||
);
|
||||
|
||||
// delete the original file that's fetched from the device. Also, clean up
|
||||
// the shared asset if the file is already uploaded.
|
||||
Future<void> delete() async {
|
||||
if (uploadFile.existsSync() && (Platform.isIOS || sharedAsset != null)) {
|
||||
await uploadFile.delete();
|
||||
}
|
||||
if (livePhotoImage != null && livePhotoVideo != null) {
|
||||
final livePhotoImageFile = File(livePhotoImage!);
|
||||
final livePhotoVideoFile = File(livePhotoVideo!);
|
||||
if (livePhotoImageFile.existsSync()) {
|
||||
await livePhotoImageFile.delete();
|
||||
}
|
||||
if (livePhotoVideoFile.existsSync()) {
|
||||
await livePhotoVideoFile.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
class FileUpdateResponse {
|
||||
final int id;
|
||||
final int updationTime;
|
||||
|
||||
FileUpdateResponse({
|
||||
required this.id,
|
||||
required this.updationTime,
|
||||
});
|
||||
}
|
||||
11
mobile/apps/photos/lib/module/upload/model/upload_data.dart
Normal file
11
mobile/apps/photos/lib/module/upload/model/upload_data.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
class UploadMetadaData {
|
||||
final Map<String, dynamic> defaultMetadata;
|
||||
final Map<String, dynamic>? publicMetadata;
|
||||
final int? currentPublicMetadataVersion;
|
||||
|
||||
UploadMetadaData({
|
||||
required this.defaultMetadata,
|
||||
required this.publicMetadata,
|
||||
required this.currentPublicMetadataVersion,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import "dart:convert";
|
||||
import "dart:core";
|
||||
import "dart:io";
|
||||
import 'dart:typed_data';
|
||||
|
||||
import "package:computer/computer.dart";
|
||||
import 'package:ente_crypto/ente_crypto.dart';
|
||||
import "package:exif_reader/exif_reader.dart";
|
||||
import 'package:logging/logging.dart';
|
||||
import "package:motion_photos/motion_photos.dart";
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import "package:photos/db/remote/table/files_table.dart";
|
||||
import "package:photos/models/api/diff/diff.dart";
|
||||
import "package:photos/models/api/metadata.dart";
|
||||
import 'package:photos/models/file/file.dart';
|
||||
import 'package:photos/models/file/file_type.dart';
|
||||
import "package:photos/models/location/location.dart";
|
||||
import "package:photos/models/metadata/file_magic.dart";
|
||||
import "package:photos/module/upload/model/media.dart";
|
||||
import "package:photos/module/upload/model/upload_data.dart";
|
||||
import "package:photos/service_locator.dart";
|
||||
import "package:photos/services/local/metadata/metadata.service.dart";
|
||||
import "package:photos/utils/exif_util.dart";
|
||||
import "package:photos/utils/panorama_util.dart";
|
||||
|
||||
final _logger = Logger("FileUtil");
|
||||
// in Version 1, live photo hash is stored as zip's hash.
|
||||
// in V2: LivePhoto hash is stored as imgHash:vidHash
|
||||
const kCurrentMetadataVersion = 2;
|
||||
|
||||
Future<int?> motionVideoIndex(Map<String, dynamic> args) async {
|
||||
final String path = args['path'];
|
||||
return (await MotionPhotos(path).getMotionVideoIndex())?.start;
|
||||
}
|
||||
|
||||
Future<UploadMetadaData> getUploadMetadata(
|
||||
UploadMedia uploadMedia,
|
||||
EnteFile file,
|
||||
) async {
|
||||
final FileType fileType = file.fileType;
|
||||
Map<String, IfdTag>? exifData;
|
||||
if (fileType == FileType.image) {
|
||||
exifData = await readExifAsync(uploadMedia.uploadFile);
|
||||
} else if (fileType == FileType.livePhoto) {
|
||||
final imageFile = File(uploadMedia.livePhotoImage!);
|
||||
exifData = await readExifAsync(imageFile);
|
||||
}
|
||||
final ParsedExifDateTime? exifTime =
|
||||
exifData != null ? parseExifTime(exifData) : null;
|
||||
bool? isPanorama;
|
||||
int? mviIndex;
|
||||
if (fileType == FileType.image) {
|
||||
isPanorama = isPanoFromExif(exifData);
|
||||
if (isPanorama != true) {
|
||||
try {
|
||||
final xmpData = await getXmp(uploadMedia.uploadFile);
|
||||
isPanorama = isPanoFromXmp(xmpData);
|
||||
} catch (_) {}
|
||||
isPanorama ??= false;
|
||||
}
|
||||
if (Platform.isAndroid) {
|
||||
try {
|
||||
mviIndex = await Computer.shared().compute(
|
||||
motionVideoIndex,
|
||||
param: {'path': uploadMedia.uploadFile.path},
|
||||
taskName: 'motionPhotoIndex',
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.severe('error while detecthing motion photo start index', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
final Map<String, dynamic> defaultMetadata = await getMetadata(
|
||||
uploadMedia,
|
||||
exifTime,
|
||||
exifData,
|
||||
file,
|
||||
);
|
||||
Metadata? existingPublicMetadata;
|
||||
if (file.rAsset != null) {
|
||||
final result =
|
||||
await remoteDB.getIDToMetadata({file.rAsset!.id}, public: true);
|
||||
existingPublicMetadata = result[file.rAsset!.id];
|
||||
}
|
||||
final Map<String, dynamic> publicMetadata = _buildPublicMagicData(
|
||||
exifTime,
|
||||
existingPublicMetadata,
|
||||
width: uploadMedia.localAsset?.width,
|
||||
height: uploadMedia.localAsset?.height,
|
||||
isPanorama: isPanorama,
|
||||
motionPhotoStartIndex: mviIndex,
|
||||
noThumbnail: uploadMedia.thumbnail == null,
|
||||
);
|
||||
|
||||
return UploadMetadaData(
|
||||
defaultMetadata: defaultMetadata,
|
||||
publicMetadata: publicMetadata.isEmpty ? null : publicMetadata,
|
||||
currentPublicMetadataVersion: existingPublicMetadata?.version,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getMetadata(
|
||||
UploadMedia uploadMedia,
|
||||
ParsedExifDateTime? exifTime,
|
||||
Map<String, IfdTag>? exifData,
|
||||
EnteFile file,
|
||||
) async {
|
||||
final AssetEntity? asset = uploadMedia.localAsset;
|
||||
|
||||
final FileType fileType = file.fileType;
|
||||
final String? deviceFolder = file.deviceFolder;
|
||||
|
||||
int? duration;
|
||||
final (int creationTime, int modificationTime) =
|
||||
LocalMetadataService.computeCreationAndModification(
|
||||
asset,
|
||||
exifData,
|
||||
);
|
||||
String? title = file.title;
|
||||
final Location? location = await LocalMetadataService.detectLocation(
|
||||
fileType.isVideo,
|
||||
asset,
|
||||
uploadMedia.uploadFile,
|
||||
exifData,
|
||||
);
|
||||
// asset can be null for files shared to app
|
||||
if (asset != null) {
|
||||
if (asset.type == AssetType.video) {
|
||||
duration = asset.duration;
|
||||
}
|
||||
if (title == null || title.isEmpty) {
|
||||
_logger.warning("Title was missing ${file.tag}");
|
||||
title = await asset.titleAsync;
|
||||
}
|
||||
}
|
||||
|
||||
final metadata = <String, dynamic>{
|
||||
"localID": asset?.id,
|
||||
"hash": uploadMedia.hash,
|
||||
"version": kCurrentMetadataVersion,
|
||||
"title": title,
|
||||
"deviceFolder": deviceFolder,
|
||||
"creationTime": creationTime,
|
||||
"modificationTime": modificationTime,
|
||||
"fileType": fileType.index,
|
||||
};
|
||||
|
||||
if (asset != null) {
|
||||
metadata["subType"] = asset.subtype;
|
||||
}
|
||||
if (Location.isValidLocation(location)) {
|
||||
metadata["latitude"] = location!.latitude;
|
||||
metadata["longitude"] = location.longitude;
|
||||
}
|
||||
if (duration != null) {
|
||||
metadata["duration"] = duration;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _buildPublicMagicData(
|
||||
ParsedExifDateTime? parsedExifTime,
|
||||
Metadata? existingPublicMetadata, {
|
||||
required int? width,
|
||||
required int? height,
|
||||
required bool? isPanorama,
|
||||
required int? motionPhotoStartIndex,
|
||||
required bool noThumbnail,
|
||||
}) {
|
||||
final Map<String, dynamic> pubMetadata = {};
|
||||
if ((height ?? 0) != 0 && (width ?? 0) != 0) {
|
||||
pubMetadata[heightKey] = height;
|
||||
pubMetadata[widthKey] = width;
|
||||
}
|
||||
pubMetadata[mediaTypeKey] = isPanorama == true ? 1 : 0;
|
||||
if (motionPhotoStartIndex != null) {
|
||||
pubMetadata[motionVideoIndexKey] = motionPhotoStartIndex;
|
||||
}
|
||||
if (noThumbnail) {
|
||||
pubMetadata[noThumbKey] = true;
|
||||
}
|
||||
if (parsedExifTime?.dateTime != null) {
|
||||
pubMetadata[dateTimeKey] = parsedExifTime!.dateTime;
|
||||
}
|
||||
if (parsedExifTime?.offsetTime != null) {
|
||||
pubMetadata[offsetTimeKey] = parsedExifTime!.offsetTime;
|
||||
}
|
||||
|
||||
final Map<String, dynamic> jsonToUpdate =
|
||||
existingPublicMetadata?.data ?? <String, dynamic>{};
|
||||
pubMetadata.forEach((key, value) {
|
||||
jsonToUpdate[key] = value;
|
||||
});
|
||||
return jsonToUpdate;
|
||||
}
|
||||
|
||||
Future<MetadataRequest?> getPubMetadataRequest(
|
||||
Map<String, dynamic> jsonToUpdate,
|
||||
Uint8List fileKey,
|
||||
int? publicMetadataVersion,
|
||||
) async {
|
||||
if (jsonToUpdate.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final int currentVersion = (publicMetadataVersion ?? 0);
|
||||
final encryptedMMd = await CryptoUtil.encryptChaCha(
|
||||
utf8.encode(jsonEncode(jsonToUpdate)),
|
||||
fileKey,
|
||||
);
|
||||
return MetadataRequest(
|
||||
version: currentVersion == 0 ? 1 : currentVersion,
|
||||
count: jsonToUpdate.length,
|
||||
data: CryptoUtil.bin2base64(encryptedMMd.encryptedData!),
|
||||
header: CryptoUtil.bin2base64(encryptedMMd.header!),
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user