Compare commits

..

401 Commits

Author SHA1 Message Date
Neeraj Gupta
38503e8673 Merge remote-tracking branch 'origin/main' into remote_db 2025-09-02 10:34:47 +05:30
Neeraj Gupta
c5319f2ba8 Merge remote-tracking branch 'origin/main' into remote_db 2025-08-21 17:19:42 +05:30
Neeraj Gupta
5d851d8f90 Add model for localAsset 2025-08-08 16:11:52 +05:30
Neeraj Gupta
dd76317bb0 Clean up EnteFile 2025-08-07 15:45:55 +05:30
Neeraj Gupta
5519981f9c Stop reading redundant data 2025-08-07 15:43:55 +05:30
Neeraj Gupta
1c249560c0 Clean up EnteFile 2025-08-07 15:10:56 +05:30
Neeraj Gupta
130cc9137c File:Duration Switch to get only prop 2025-08-07 13:53:29 +05:30
Neeraj Gupta
95f2d282b5 Clean up EnteFile 2025-08-07 13:51:04 +05:30
Neeraj Gupta
e6668606db Minimize data read 2025-08-07 13:37:43 +05:30
Neeraj Gupta
316c767ffa Merge branch 'reduceLog' into remote_db 2025-08-07 11:32:25 +05:30
Neeraj Gupta
c08b0f7aa4 Build changes 2025-08-07 11:08:15 +05:30
Neeraj Gupta
b5a4bcf98f Clean up EnteFile 2025-08-07 11:08:03 +05:30
Neeraj Gupta
dc06a2e193 Use getter for title 2025-08-07 10:41:20 +05:30
Neeraj Gupta
c29446e00a Add index 2025-08-06 14:51:41 +05:30
Neeraj Gupta
5aa36921d8 Use remoteDB 2025-08-06 12:38:06 +05:30
Neeraj Gupta
3b3af65d74 Rewrite reload and cache clear check 2025-08-06 11:35:12 +05:30
Neeraj Gupta
9ad88d3908 Filter out files for which we don't need to create streams 2025-08-06 10:50:34 +05:30
Neeraj Gupta
012e9091f0 Use remote DB 2025-08-06 10:34:43 +05:30
Neeraj Gupta
8ff0f237e7 Use remote DB 2025-08-06 10:17:27 +05:30
Neeraj Gupta
c80e4a65b8 Set state context early 2025-08-06 09:14:08 +05:30
Neeraj Gupta
c317f2494f Fix parsing 2025-08-06 09:13:42 +05:30
Neeraj Gupta
264b0b151a Merge remote-tracking branch 'origin/main' into remote_db 2025-08-05 14:41:02 +05:30
Neeraj Gupta
e5cb3e7005 Hide uploaded local files from homescreen 2025-08-05 14:31:38 +05:30
Neeraj Gupta
adc1939638 Use existing method 2025-08-05 13:15:12 +05:30
Neeraj Gupta
2cc1c36b7b Flatten all remote asset fields 2025-08-05 13:12:34 +05:30
Neeraj Gupta
b99450615e Remove unused field 2025-08-05 09:35:02 +05:30
Neeraj Gupta
5de1f0c93b Switch to remoteDB 2025-07-31 16:24:23 +05:30
Neeraj Gupta
ee902a5ccb ML typecast fixes 2025-07-31 15:39:53 +05:30
Neeraj Gupta
9e56afaa73 Fixed query 2025-07-31 15:35:48 +05:30
Neeraj Gupta
77f8c3f712 Fix: Specify type 2025-07-31 15:04:43 +05:30
Neeraj Gupta
f7a0a414db Fix: Use new db to get fileKey 2025-07-31 15:02:25 +05:30
Neeraj Gupta
bd49b8464b Minor db query fix 2025-07-31 14:20:55 +05:30
Neeraj Gupta
0cbfa319ba Use remote db for dedupe 2025-07-31 14:15:25 +05:30
Neeraj Gupta
95e05f167c Merge remote-tracking branch 'origin/main' into remote_db 2025-07-31 12:07:04 +05:30
Neeraj Gupta
84ef4c2d0b Clean up 2025-07-31 11:22:24 +05:30
Neeraj Gupta
53c14dff01 Refactor 2025-07-31 10:34:42 +05:30
Neeraj Gupta
02ef29fd8f Consolidate shared assets deletion logic 2025-07-31 10:29:00 +05:30
Neeraj Gupta
1848f1a94b Use remoteDB 2025-07-29 15:14:36 +05:30
Neeraj Gupta
276c38236f Use new db to identify backed up files 2025-07-29 14:53:26 +05:30
Neeraj Gupta
5e3e3b4427 Clean up 2025-07-28 16:53:57 +05:30
Neeraj Gupta
b8dab3ea1c Use new db for archive file count 2025-07-28 14:57:36 +05:30
Neeraj Gupta
2dc32f5339 Strip out exif from metadata 2025-07-28 14:54:40 +05:30
Neeraj Gupta
9fd6bc4974 Fix query 2025-07-28 14:54:24 +05:30
Neeraj Gupta
3740e9e29d Gracefully handle partial cleanup 2025-07-28 14:33:34 +05:30
Neeraj Gupta
118dde38a2 Refactor 2025-07-28 11:48:01 +05:30
Neeraj Gupta
cd15fe86c6 Fix delete 2025-07-28 11:47:55 +05:30
Neeraj Gupta
693a40cc24 Refactor save asset for edits 2025-07-26 12:17:53 +05:30
Neeraj Gupta
2cfa8497da Merge remote-tracking branch 'origin/main' into remote_db 2025-07-26 11:53:48 +05:30
Neeraj Gupta
3f6b9d7ae6 Refactor 2025-07-26 11:13:54 +05:30
Neeraj Gupta
e71b6dbb63 Remove unused method 2025-07-26 11:04:49 +05:30
Neeraj Gupta
44722c40c2 Merge branch 'main' into remote_db 2025-07-24 16:22:57 +05:30
Neeraj Gupta
bfd13b99d2 Fix compile error 2025-07-21 11:36:59 +05:30
Neeraj Gupta
e82d878fa8 Merge branch 'main' into remote_db 2025-07-21 10:46:57 +05:30
Neeraj Gupta
0587813c70 Rename 2025-07-14 14:43:08 +05:30
Neeraj Gupta
b7712a7c51 Clean up 2025-07-14 14:42:16 +05:30
Neeraj Gupta
213f3cd122 Clean up 2025-07-14 11:44:13 +05:30
Neeraj Gupta
d103274da5 Add file matching logic before upload 2025-07-14 11:04:44 +05:30
Neeraj Gupta
28b244ebc5 Use fk to keep updated mapping info 2025-07-14 09:53:59 +05:30
Neeraj Gupta
7d24dea7bc Merge branch 'main' into remote_db 2025-07-11 16:56:34 +05:30
Neeraj Gupta
4a358a7793 Refactor 2025-07-11 11:09:00 +05:30
Neeraj Gupta
699249cd26 Upload [1/x] 2025-07-11 10:15:35 +05:30
Neeraj Gupta
7125ac7419 Simplify 2025-07-10 10:34:50 +05:30
Neeraj Gupta
de67b6e9fc Rename column and minor fixes 2025-07-09 15:20:49 +05:30
Neeraj Gupta
39868de5d9 Update schema for shared assets 2025-07-09 14:22:33 +05:30
Neeraj Gupta
751550d469 Refactor 2025-07-09 12:01:46 +05:30
Neeraj Gupta
6f0034dd9d Merge remote-tracking branch 'origin/main' into remote_db 2025-07-09 10:07:55 +05:30
Neeraj Gupta
7bd6180ebf Reflect backup status for device folder 2025-07-09 10:06:25 +05:30
Neeraj Gupta
5a3ae5f97c Log stack trace 2025-07-09 10:05:45 +05:30
Neeraj Gupta
e447058573 Simplify 2025-07-09 09:08:21 +05:30
Neeraj Gupta
8a7e8b8237 Fix queue clean up on backup folder update 2025-07-08 17:57:38 +05:30
Neeraj Gupta
ed7b1be591 Re-organize 2025-07-08 15:08:56 +05:30
Neeraj Gupta
9a5ef8b634 Clean up 2025-07-08 11:29:37 +05:30
Neeraj Gupta
c2668387b5 Refactor to use new upload media and metadata 2025-07-08 11:01:37 +05:30
Neeraj Gupta
d265cf62c7 Separate model to get upload media 2025-07-08 09:18:57 +05:30
Neeraj Gupta
a67c3b0624 Clean up fields 2025-07-07 16:57:06 +05:30
Neeraj Gupta
306c78fbb0 Refactor 2025-07-07 16:09:40 +05:30
Neeraj Gupta
e4f9dd6b33 Simplify new files notification + avoid redundant compute 2025-07-07 15:57:47 +05:30
Neeraj Gupta
5fec59f0fe Clean up 2025-07-07 15:20:32 +05:30
Neeraj Gupta
53050ca25e Map remote files to local during diff sync 2025-07-07 14:31:37 +05:30
Neeraj Gupta
926aa42168 Merge branch 'main' into remote_db 2025-07-07 09:52:59 +05:30
Neeraj Gupta
f4f8141b99 Move mobile -> mobile/apps/photos 2025-07-04 16:52:07 +05:30
Neeraj Gupta
3d044dd3d4 Refactor metadata computation 2025-07-04 14:06:11 +05:30
Neeraj Gupta
8699ad2f01 New upload media data model 2025-07-04 11:52:27 +05:30
Neeraj Gupta
01aad531f1 Generated changes 2025-07-04 10:05:42 +05:30
Neeraj Gupta
6340d9f646 Simplify 2025-07-04 09:37:17 +05:30
Neeraj Gupta
0f944b1796 Simplify 2025-07-04 09:33:02 +05:30
Neeraj Gupta
1d8533168f Mark fields non-nullable 2025-07-03 17:32:58 +05:30
Neeraj Gupta
2fca1ba534 Add location in shared assets 2025-07-03 16:29:38 +05:30
Neeraj Gupta
2d386c769b Simplify 2025-07-03 11:41:14 +05:30
Neeraj Gupta
1b41d81839 Refactor 2025-07-02 20:08:49 +05:30
Neeraj Gupta
487156c7df Use computer for parsing XMP 2025-07-02 19:32:19 +05:30
Neeraj Gupta
35c54111e7 Remove unused import 2025-07-02 17:24:55 +05:30
Neeraj Gupta
d71340fbdd Refactor 2025-07-02 17:19:35 +05:30
Neeraj Gupta
f32ea85ee2 Refactor 2025-07-02 16:37:06 +05:30
Neeraj Gupta
6397ab888a Merge branch 'main' into remote_db 2025-07-02 14:48:53 +05:30
Neeraj Gupta
5a73043b63 Measure time 2025-07-02 14:13:39 +05:30
Neeraj Gupta
f258c40e98 Measure time 2025-07-02 14:13:20 +05:30
Neeraj Gupta
36b6476049 Use remote DB 2025-07-02 11:32:47 +05:30
Neeraj Gupta
c4c5ea150f Merge branch 'main' into remote_db 2025-07-02 09:30:08 +05:30
Neeraj Gupta
1956b3788b Rename 2025-07-01 15:52:11 +05:30
Neeraj Gupta
72cbddff6d Use upload queue for pending uploads 2025-07-01 15:42:48 +05:30
Neeraj Gupta
17670d5538 Use asset queue db for queueing pending upload 2025-07-01 15:25:52 +05:30
Neeraj Gupta
8cfd80663e Merge branch 'main' into remote_db 2025-07-01 14:55:11 +05:30
Neeraj Gupta
f285e2d706 Use lAsset for video type 2025-06-30 17:49:15 +05:30
Neeraj Gupta
f60e074dd5 Use tag instead of generatedID 2025-06-30 17:00:45 +05:30
Neeraj Gupta
140eae6859 Use tag instead of genID 2025-06-30 16:52:23 +05:30
Neeraj Gupta
23ee022472 Refactor 2025-06-30 16:46:08 +05:30
Neeraj Gupta
b1cf3f9fb0 Refactor live photo 2025-06-30 15:50:46 +05:30
Neeraj Gupta
dbbb80a817 Merge remote-tracking branch 'origin/main' into remote_db 2025-06-30 14:35:46 +05:30
Neeraj Gupta
91a10634cc Remove redundant import 2025-06-30 13:01:53 +05:30
Neeraj Gupta
0a2c230254 Merge remote-tracking branch 'origin/main' into remote_db 2025-06-30 12:24:18 +05:30
Neeraj Gupta
6745b110df Refactor shared assets 2025-06-30 12:23:01 +05:30
Neeraj Gupta
7061161181 Rename 2025-06-30 11:54:41 +05:30
Neeraj Gupta
f0026f0a81 Refactor 2025-06-30 11:52:36 +05:30
Neeraj Gupta
acec985bcb Add shared asset service 2025-06-30 11:31:42 +05:30
Neeraj Gupta
c8103a9e06 Refactor 2025-06-28 13:54:16 +05:30
Neeraj Gupta
02f64ad45f Refactor 2025-06-27 15:32:19 +05:30
Neeraj Gupta
d0931d1d0e Refactor 2025-06-27 15:17:08 +05:30
Neeraj Gupta
5c78de5355 Refactor 2025-06-27 14:08:47 +05:30
Neeraj Gupta
1aa9f61419 Rename 2025-06-27 12:49:00 +05:30
Neeraj Gupta
0f2b51d1a5 Remove redundant method 2025-06-27 12:46:29 +05:30
Neeraj Gupta
9fef560d15 Refactor 2025-06-27 12:02:01 +05:30
Neeraj Gupta
09c7bfd717 refactor 2025-06-26 14:44:44 +05:30
Neeraj Gupta
2ff059a701 Remove unused file 2025-06-26 14:25:05 +05:30
Neeraj Gupta
70b043d34a Migrate restore to new store 2025-06-26 14:19:15 +05:30
Neeraj Gupta
15a00379b5 Use remoteDB for linking 2025-06-26 10:01:25 +05:30
Neeraj Gupta
7246ade2ae Rename 2025-06-26 09:50:42 +05:30
Neeraj Gupta
7cad0a83d2 Clean up 2025-06-26 00:04:00 +05:30
Neeraj Gupta
e6703aef65 Simplify 2025-06-25 23:38:03 +05:30
Neeraj Gupta
662dfad7ca Remove unused method 2025-06-25 23:20:18 +05:30
Neeraj Gupta
466ab30f8b Improve handling of empty collection for cover 2025-06-25 22:55:04 +05:30
Neeraj Gupta
12c1845a5f Clean up 2025-06-25 22:43:44 +05:30
Neeraj Gupta
2d0202df36 Refactor magic metadata 2025-06-25 22:24:17 +05:30
Neeraj Gupta
2ec4f5a7e5 Use rAsset for metadata 2025-06-25 21:55:14 +05:30
Neeraj Gupta
6723bed1b0 Refactor filemagic service to use rAsset 2025-06-25 17:22:09 +05:30
Neeraj Gupta
84e8ce519e Fix error parsing for internal dialog 2025-06-25 16:43:05 +05:30
Neeraj Gupta
1b50528181 Remove unused filed 2025-06-25 14:58:23 +05:30
Neeraj Gupta
08dff77ad4 Refactor 2025-06-25 14:58:11 +05:30
Neeraj Gupta
713abce89a Refactor: get size from remoteAsset 2025-06-25 13:34:17 +05:30
Neeraj Gupta
da15593a47 Remove unused method 2025-06-25 12:44:40 +05:30
Neeraj Gupta
fc31cc61d1 Avoid redundant async 2025-06-25 12:44:34 +05:30
Neeraj Gupta
9c6259b713 Use remote db for favroite cache 2025-06-25 12:29:50 +05:30
Neeraj Gupta
ed603232a5 Merge branch 'main' into remote_db 2025-06-25 12:29:27 +05:30
Neeraj Gupta
ccd89d3451 Remove unused method 2025-06-25 11:28:28 +05:30
Neeraj Gupta
647b2ef4a7 Cache remoteAssets entry 2025-06-25 11:07:50 +05:30
Neeraj Gupta
f54c79462e Clear remote db on logout 2025-06-25 11:06:10 +05:30
Neeraj Gupta
eb81d96ddf Fix incorrect request key 2025-06-25 11:05:44 +05:30
Neeraj Gupta
57363a24ef Fix minor bugs 2025-06-25 09:23:34 +05:30
Neeraj Gupta
9431995e8c Clean up 2025-06-24 14:35:06 +05:30
Neeraj Gupta
64b86376f6 Cleanup 2025-06-24 13:31:58 +05:30
Neeraj Gupta
a825367c49 Merge remote-tracking branch 'origin/main' into remote_db 2025-06-24 12:42:22 +05:30
Neeraj Gupta
1ffbb27ac5 Fix schema 2025-06-24 12:42:07 +05:30
Neeraj Gupta
d4add9f7ef Fix 2025-06-24 12:41:54 +05:30
Neeraj Gupta
541494613f Use CF for file key derivation 2025-06-24 12:19:55 +05:30
Neeraj Gupta
2b3427e40b Track shared assets separately 2025-06-24 11:55:28 +05:30
Neeraj Gupta
a57c9e881d Use remote db 2025-06-24 09:49:51 +05:30
Neeraj Gupta
d15f1e15ce Use remote db for file copy 2025-06-23 16:30:26 +05:30
Neeraj Gupta
0411f8ad40 Clean up 2025-06-23 15:04:42 +05:30
Neeraj Gupta
2981816c90 Use remotedb for trash 2025-06-23 14:54:48 +05:30
Neeraj Gupta
a6de98ef68 Add trash time 2025-06-23 14:35:13 +05:30
Neeraj Gupta
18156ce8bc Add trash group type 2025-06-23 14:24:56 +05:30
Neeraj Gupta
458c1cf86d Remove log 2025-06-21 11:28:05 +05:30
Neeraj Gupta
90c0874608 Merge branch 'main' into remote_db 2025-06-21 10:13:22 +05:30
Neeraj Gupta
928ffba4d7 rename 2025-06-21 09:14:01 +05:30
Neeraj Gupta
0701212540 Clean up 2025-06-21 08:46:49 +05:30
Neeraj Gupta
347bf4d2e0 Move 2025-06-21 07:57:41 +05:30
Neeraj Gupta
2729edfded rename 2025-06-21 07:57:26 +05:30
Neeraj Gupta
4e8d2c5cea Use local or remote id for selection 2025-06-21 07:56:55 +05:30
Neeraj Gupta
84e9336672 Use new abstraction to fetch public files 2025-06-20 14:22:02 +05:30
Neeraj Gupta
cecdea3f93 Rename 2025-06-20 13:21:14 +05:30
Neeraj Gupta
37674deba0 Implement move 2025-06-20 13:02:42 +05:30
Neeraj Gupta
733be57df8 Merge remote-tracking branch 'origin/main' into remote_db 2025-06-19 22:49:29 +05:30
Neeraj Gupta
74df52baf1 rename 2025-06-19 16:28:35 +05:30
Neeraj Gupta
b817c4475e Refactor 2025-06-19 16:09:22 +05:30
Neeraj Gupta
f95dac31d2 Merge remote-tracking branch 'origin/main' into remote_db 2025-06-19 11:44:20 +05:30
Neeraj Gupta
151289b24a Use remoteDB during add to collection 2025-06-17 17:24:16 +05:30
Neeraj Gupta
5dac9d4dd6 Rename 2025-06-17 16:44:13 +05:30
Neeraj Gupta
43b9dbdc54 Refactor 2025-06-17 16:14:56 +05:30
Neeraj Gupta
9d3caaa5d5 Use remoteDB to get files within duration 2025-06-17 15:45:13 +05:30
Neeraj Gupta
7eda2ed24e remove unused import 2025-06-17 14:24:23 +05:30
Neeraj Gupta
30df5271b4 Fix query 2025-06-17 14:24:12 +05:30
Neeraj Gupta
4b1f7612a3 Rename 2025-06-17 14:15:40 +05:30
Neeraj Gupta
bf6521e8d5 Use new remoteDB 2025-06-17 14:00:50 +05:30
Neeraj Gupta
4bac1bcb1d Merge remote-tracking branch 'origin/main' into remote_db 2025-06-17 13:34:12 +05:30
Neeraj Gupta
b123635584 Merge remote-tracking branch 'origin/main' into remote_db 2025-06-17 12:33:29 +05:30
Neeraj Gupta
d815143bb4 Rewrite upload queue logic 1/x 2025-06-16 14:02:12 +05:30
Neeraj Gupta
ff6228497f Add tables to support shared media & upload tracking 2025-06-11 17:02:03 +05:30
Neeraj Gupta
7469578e77 Merge branch 'main' into remote_db 2025-06-11 12:38:31 +05:30
Neeraj Gupta
76afef6149 Support for persiting path backup config 2025-06-10 15:58:18 +05:30
Neeraj Gupta
2b3178495a Fix build 2025-06-09 21:54:46 +05:30
Neeraj Gupta
d6f3ff8db3 Merge branch 'main' into remote_db 2025-06-09 13:15:33 +05:30
Neeraj Gupta
7b0ef2b0c0 Add table to store path backup config 2025-06-03 15:52:36 +05:30
Neeraj Gupta
35f95010ea Merge branch 'main' into remote_db 2025-06-03 12:46:53 +05:30
Neeraj Gupta
233f0ec1e1 Fix shared collections decryption 2025-05-28 16:36:46 +05:30
Neeraj Gupta
64820ff5fa Merge branch 'main' into remote_db 2025-05-28 16:36:30 +05:30
Neeraj Gupta
86ffd4e1e6 Merge branch 'main' into remote_db 2025-05-26 16:27:39 +05:30
Neeraj Gupta
436a02d352 Merge branch 'main' into remote_db 2025-05-19 15:34:32 +05:30
Neeraj Gupta
0f3b8bae48 Add models for upload mapping 2025-05-09 15:12:25 +05:30
Neeraj Gupta
0f270a379f Rename 2025-05-08 16:57:10 +05:30
Neeraj Gupta
609f6b8e18 Merge branch 'main' into remote_db 2025-05-08 15:28:06 +05:30
Neeraj Gupta
7896b397c2 Merge branch 'main' into remote_db 2025-05-08 13:08:08 +05:30
Neeraj Gupta
097078bd24 Switch to remoteDB 2025-05-02 16:34:11 +05:30
Neeraj Gupta
e43e3c4230 Switch to remoteDB 2025-05-02 16:26:18 +05:30
Neeraj Gupta
439f1ff0fb keep reference to collectionFileEntry 2025-05-02 16:09:44 +05:30
Neeraj Gupta
e5d78cfd99 Remove unused method 2025-05-02 16:09:26 +05:30
Neeraj Gupta
bd76e66abf perf refactor 2025-05-02 15:41:58 +05:30
Neeraj Gupta
9e6d7908a9 Refactor 2025-05-02 15:14:58 +05:30
Neeraj Gupta
281735e172 Show hidden files from latestDB 2025-05-02 12:28:18 +05:30
Neeraj Gupta
bc93aca110 Merge remote-tracking branch 'origin/main' into remote_db 2025-05-02 11:00:02 +05:30
Neeraj Gupta
df6e409ca1 Remove debugCaption 2025-04-30 15:52:35 +05:30
Neeraj Gupta
7489821434 Refactor 2025-04-30 15:50:31 +05:30
Neeraj Gupta
52e0f04ec2 Merge remote-tracking branch 'origin/main' into remote_db 2025-04-30 15:06:51 +05:30
Neeraj Gupta
ad88ce632c Remove unused method 2025-04-30 14:32:22 +05:30
Neeraj Gupta
26222ec836 Use remoteDB 2025-04-30 14:10:04 +05:30
Neeraj Gupta
beeaee4fd9 Switch to remote db 1/51 2025-04-30 13:18:17 +05:30
Neeraj Gupta
dc98c7bcf5 Remove unused method 2025-04-30 13:11:19 +05:30
Neeraj Gupta
82497563c2 Switch to remoteDB 2025-04-30 13:03:58 +05:30
Neeraj Gupta
4a9b4520d2 Rename 2025-04-30 12:25:52 +05:30
Neeraj Gupta
756c8e5b7d rename 2025-04-30 12:14:53 +05:30
Neeraj Gupta
dffb920cab Switch to remoteDB 2025-04-30 12:12:34 +05:30
Neeraj Gupta
fa7ddbba0c Merge branch 'main' into remote_db 2025-04-30 11:20:44 +05:30
Neeraj Gupta
13a068969c Catch uncaught exception to avoid splash screen issue 2025-04-30 11:10:18 +05:30
Neeraj Gupta
717e8c8b7e Disable iOS battery check in debugmode 2025-04-30 11:09:59 +05:30
Neeraj Gupta
015adb595c Support for DB filters on home screen 2025-04-30 10:37:53 +05:30
Neeraj Gupta
91635d2e7d Schema update 2025-04-30 10:32:10 +05:30
Neeraj Gupta
cd3499a004 Merge branch 'main' into remote_db 2025-04-30 09:27:09 +05:30
Neeraj Gupta
92a964cda6 Remove unused method 2025-04-28 15:47:00 +05:30
Neeraj Gupta
a9ba615962 Switch to new remotedb 2025-04-28 15:36:43 +05:30
Neeraj Gupta
f2b0c11622 Merge remote-tracking branch 'origin/main' into remote_db 2025-04-28 13:55:46 +05:30
Neeraj Gupta
83b89b6bbf Merge branch 'main' into remote_db 2025-04-28 13:55:26 +05:30
Neeraj Gupta
ee0a858302 Merge branch 'main' into remote_db 2025-04-28 09:52:10 +05:30
Neeraj Gupta
b34c923a66 merge files for home screen 2025-04-25 13:04:30 +05:30
Neeraj Gupta
fd927d038b Fix minor bugs 2025-04-25 12:36:57 +05:30
Neeraj Gupta
64e9902f57 Model & schema for local mapping 2025-04-25 12:36:33 +05:30
Neeraj Gupta
c3af79d113 Merge branch 'main' into remote_db 2025-04-24 08:50:49 +05:30
Neeraj Gupta
d87e679650 use remoteDB for collection cover 2025-04-23 12:00:21 +05:30
Neeraj Gupta
7e2242dc69 Show collection files 2025-04-22 16:34:47 +05:30
Neeraj Gupta
9adc207b02 Gradually load on device files 2025-04-22 15:10:09 +05:30
Neeraj Gupta
36049f6633 Treat default 0.0,0.0 as missing location 2025-04-22 14:14:32 +05:30
Neeraj Gupta
9341bc95ee Clean up 2025-04-22 13:57:38 +05:30
Neeraj Gupta
252dca1a01 Clean up 2025-04-22 13:07:53 +05:30
Neeraj Gupta
70501054d2 use remoteDb 2025-04-22 12:50:14 +05:30
Neeraj Gupta
be012e0a28 use remoteDb 2025-04-22 12:18:13 +05:30
Neeraj Gupta
340a0c097f Remove unused method 2025-04-22 12:11:20 +05:30
Neeraj Gupta
7fc8649455 use remoteDb 2025-04-22 11:58:56 +05:30
Neeraj Gupta
c97a313edb use remoteDb 2025-04-22 11:52:34 +05:30
Neeraj Gupta
480fdc84dc Merge remote-tracking branch 'origin/main' into remote_db 2025-04-22 11:52:10 +05:30
Neeraj Gupta
c92ef45c9a Refactor 2025-04-21 16:31:39 +05:30
Neeraj Gupta
ca62012a6f Store local mapping & creation time for remote files 2025-04-21 15:04:42 +05:30
Neeraj Gupta
151a0d13a4 Sync remote assets to local 2025-04-18 14:00:14 +05:30
Neeraj Gupta
747b1b84c6 Upsert instead of replace 2025-04-18 13:59:15 +05:30
Neeraj Gupta
e060fb9823 Disable metadata and ml indexing 2025-04-18 12:14:39 +05:30
Neeraj Gupta
cd377149bc Fix parsing 2025-04-18 11:48:23 +05:30
Neeraj Gupta
d9e22a489b Fix count query 2025-04-18 11:47:34 +05:30
Neeraj Gupta
524db74bf5 Read collection file count 2025-04-17 16:45:36 +05:30
Neeraj Gupta
e1222d51a9 Merge branch 'main' into remote_db 2025-04-17 16:17:36 +05:30
Neeraj Gupta
1c68f0bb60 Refactor 2025-04-17 15:30:40 +05:30
Neeraj Gupta
f6419caf5c Disable metadata scan on ios 2025-04-17 13:50:48 +05:30
Neeraj Gupta
441bcbd187 todo 2025-04-17 13:35:07 +05:30
Neeraj Gupta
4ad3927348 Add trigger to clean up stale collection entries 2025-04-17 13:34:51 +05:30
Neeraj Gupta
4ae15e5966 Remove unused import 2025-04-17 12:17:33 +05:30
Neeraj Gupta
d67d1d3df8 Index local files 2025-04-16 15:57:08 +05:30
Neeraj Gupta
07e1d33ca8 Merge branch 'main' into remote_db 2025-04-16 10:45:51 +05:30
Neeraj Gupta
50e15fa56c offline indexing 1/x 2025-04-15 16:03:39 +05:30
Neeraj Gupta
3f262c5ba2 Add db for offline ml data 2025-04-15 15:40:27 +05:30
Neeraj Gupta
7f34870e3a Remove unused code 2025-04-15 15:39:56 +05:30
Neeraj Gupta
8ba5013926 Merge branch 'main' into remote_db 2025-04-15 11:12:12 +05:30
Neeraj Gupta
e9a24efecb Make ml related classes generic 2025-04-14 16:23:26 +05:30
Neeraj Gupta
eaf74e4059 Merge branch 'main' into remote_db 2025-04-14 10:44:22 +05:30
Neeraj Gupta
e9e1c3ca27 Add wrapper for local metadata 2025-04-12 12:51:45 +05:30
Neeraj Gupta
eb34533aed Add enum for processing state 2025-03-29 13:42:57 +05:30
Neeraj Gupta
cd042e741e Merge branch 'main' into remote_db 2025-03-27 16:43:32 +05:30
Neeraj Gupta
6e944b0b55 Process latest assets first 2025-03-27 13:56:47 +05:30
Neeraj Gupta
18b6b499dd Store time in microseconds 2025-03-27 13:51:34 +05:30
Neeraj Gupta
9205ef8219 Fix: droid metadata parsing 2025-03-27 13:50:59 +05:30
Neeraj Gupta
1ecdbdb88e Fix: get device album assets in desc order 2025-03-27 13:42:18 +05:30
Neeraj Gupta
e2bb4d723e Perform local medatascan for droid 2025-03-27 10:46:33 +05:30
Neeraj Gupta
cfe32c47f0 Merge branch 'main' into remote_db 2025-03-27 06:34:35 +05:30
Neeraj Gupta
a7f6b6589d Service for parsing local metadata 2025-03-26 22:11:59 +05:30
Neeraj Gupta
715e305e09 Add local metadata models 2025-03-26 22:08:43 +05:30
Neeraj Gupta
f7330be52c Reduce queue size 2025-03-26 16:44:08 +05:30
Neeraj Gupta
4cce54a0c6 Use local files for search 2025-03-26 16:42:15 +05:30
Neeraj Gupta
1850e9a2a6 Refactor 2025-03-26 16:24:51 +05:30
Neeraj Gupta
21e2b589cc Minor refactor 2025-03-26 16:23:34 +05:30
Neeraj Gupta
b7f8deb452 Disable smart memories in debug mode 2025-03-26 16:22:59 +05:30
Neeraj Gupta
b6ffb3ca22 Simplify schema 2025-03-26 14:39:29 +05:30
Neeraj Gupta
9351a52800 Clean up imports 2025-03-26 12:46:03 +05:30
Neeraj Gupta
c7510024c0 Use db query to get files on on device albums 2025-03-26 12:43:53 +05:30
Neeraj Gupta
63d3b1c94b Show files within on device albums 2025-03-25 16:10:50 +05:30
Neeraj Gupta
cec27b40a4 Show local device albums 2025-03-25 15:41:16 +05:30
Neeraj Gupta
2f8d0d1957 Delete old local sync 2025-03-25 15:10:49 +05:30
Neeraj Gupta
7f0a36f110 Merge branch 'main' into remote_db 2025-03-25 14:29:12 +05:30
Neeraj Gupta
355367a601 Stop photoManager callback for old local sync 2025-03-25 13:19:09 +05:30
Neeraj Gupta
c66da422cd Update local asset cache 2025-03-25 12:31:15 +05:30
Neeraj Gupta
6f90fad4a2 Fix: Store localSync time in ms 2025-03-25 12:30:52 +05:30
Neeraj Gupta
bcd6f55376 Fix: Use parameterized query params 2025-03-25 12:27:26 +05:30
Neeraj Gupta
b2766a0d4f Use thumbnail widget in the backfolder screen 2025-03-25 11:38:35 +05:30
Neeraj Gupta
ec1b95b0cd Merge branch 'main' into remote_db 2025-03-25 11:35:03 +05:30
Neeraj Gupta
4369317a4d Use lock for full sync 2025-03-25 05:47:47 +05:30
Neeraj Gupta
b18298dc62 Merge remote-tracking branch 'origin/main' into remote_db 2025-03-25 05:43:36 +05:30
Neeraj Gupta
b3e467a1a4 Return early 2025-03-25 05:43:21 +05:30
Neeraj Gupta
f8cd3d9fb4 Add retry on task cancelation 2025-03-24 17:15:18 +05:30
Neeraj Gupta
6d4756ca4b Undo cancellation 2025-03-24 16:16:44 +05:30
Neeraj Gupta
676bbb4d88 Add thumb queue 2025-03-24 16:14:43 +05:30
Neeraj Gupta
da8edfd34e Use new cache for inMem thumbnails 2025-03-24 15:43:42 +05:30
Neeraj Gupta
bf453cfaac Fix selection 2025-03-24 15:10:22 +05:30
Neeraj Gupta
35f41f044e Load thumbnail from cache 2025-03-24 15:09:36 +05:30
Neeraj Gupta
acff269695 Add global cache for image thumbnail 2025-03-24 14:58:24 +05:30
Neeraj Gupta
2b4f96dbb7 Remove task queue from existing thumb fetch 2025-03-24 14:01:50 +05:30
Neeraj Gupta
d5796e2abb Use local thumb provider for thumbnails 2025-03-24 14:00:59 +05:30
Neeraj Gupta
afe9690891 Keep reference for assetEntity 2025-03-24 11:39:20 +05:30
Neeraj Gupta
fc619bbd03 Use task queue to throttle local thumbnail fetch 2025-03-22 21:53:31 +05:30
Neeraj Gupta
a198331ffd Skip reading on disk cache for local thumbnails 2025-03-22 16:26:35 +05:30
Neeraj Gupta
c58fe5358d Add task queue 2025-03-22 16:13:17 +05:30
Neeraj Gupta
defc5164b9 Merge remote-tracking branch 'origin/main' into remote_db 2025-03-22 15:17:42 +05:30
Neeraj Gupta
97d4fb0693 Generated changes 2025-03-22 15:17:30 +05:30
Neeraj Gupta
e708564cb9 Show local assets on home screen 2025-03-22 15:13:19 +05:30
Neeraj Gupta
92068e026b Fix mapping 2025-03-22 14:16:07 +05:30
Neeraj Gupta
8a079ab4f4 Disable existing local & remote sync 2025-03-22 13:27:44 +05:30
Neeraj Gupta
b41c57cb8d Speed up backup folder selection page 2025-03-22 13:26:20 +05:30
Neeraj Gupta
87167e49fc Remove deadlock 2025-03-21 16:59:20 +05:30
Neeraj Gupta
089d1dcd10 Copy ios proj from main 2025-03-21 14:26:03 +05:30
Neeraj Gupta
db02e66124 Merge branch 'main' into remote_db 2025-03-21 14:18:05 +05:30
Neeraj Gupta
964066bf31 Merge remote-tracking branch 'origin/main' into remote_db 2025-03-21 11:09:22 +05:30
Neeraj Gupta
643220e595 Merge branch 'main' into remote_db 2025-03-20 16:47:55 +05:30
Neeraj Gupta
e871498161 Disable remote sync 2025-03-20 15:06:06 +05:30
Neeraj Gupta
31a88b74df Merge branch 'main' into remote_db 2025-03-20 14:05:00 +05:30
Neeraj Gupta
1801258fea Show thumbnail on backup folder 2025-03-19 22:33:22 +05:30
Neeraj Gupta
27acc2125b Fix log 2025-03-19 21:45:48 +05:30
Neeraj Gupta
60b7a91756 Add support for caching local db 2025-03-19 21:45:31 +05:30
Neeraj Gupta
b38a01820d Avoid redundant db txn 2025-03-19 16:25:18 +05:30
Neeraj Gupta
347d5a7a72 Add local image provider for thumbnail 2025-03-19 16:03:47 +05:30
Neeraj Gupta
82979ac729 Fix bug in computing fullDiff 2025-03-19 14:09:22 +05:30
Neeraj Gupta
5768eeb1fe Use trigger instead of fk to avoid redundant deletion 2025-03-19 14:07:58 +05:30
Neeraj Gupta
7a9ff9877a Add new screen for backup 2025-03-19 14:00:51 +05:30
Neeraj Gupta
3565540a61 get assetPath from local 2025-03-19 11:34:41 +05:30
Neeraj Gupta
06d78e5d6a Merge remote-tracking branch 'origin/main' into remote_db 2025-03-19 10:29:54 +05:30
Neeraj Gupta
6c11b76c11 [mob] Refactor 2025-03-17 17:16:43 +05:30
Neeraj Gupta
749cfde7d8 [mob] Support for enabling foreign key 2025-03-17 16:28:49 +05:30
Neeraj Gupta
6bcaa8ae26 [mob] Refactor 2025-03-17 16:27:12 +05:30
Neeraj Gupta
99c6318b0e Merge branch 'db_refactor' into remote_db 2025-03-17 15:35:02 +05:30
Neeraj Gupta
abf789a4aa rename 2025-03-17 14:12:39 +05:30
Neeraj Gupta
f5d3712cbb [mob] Remove unused code + update debouncer 2025-03-17 13:58:01 +05:30
Neeraj Gupta
45dd540abc [mob] move 2025-03-17 13:40:29 +05:30
Neeraj Gupta
865a736bdd [mob] Switch to leading debouncer 2025-03-17 13:29:35 +05:30
Neeraj Gupta
75bd1bfef6 [mob] Avoid running two fullSync 2025-03-17 13:27:57 +05:30
Neeraj Gupta
4cf67fe171 [mob] Fix delete from local db 2025-03-17 12:32:11 +05:30
Neeraj Gupta
65895328dc [mob] Fix full diff computation 2025-03-17 11:46:03 +05:30
Neeraj Gupta
a1c20b9c8a Merge remote-tracking branch 'origin/main' into remote_db 2025-03-17 11:25:45 +05:30
Neeraj Gupta
de9def5370 [mob] Rename 2025-03-17 11:25:24 +05:30
Neeraj Gupta
9cc723a280 [mob] Local local device assets info in db 2025-03-17 11:03:04 +05:30
Neeraj Gupta
16beae2a82 [mob] Rename 2025-03-15 21:56:10 +05:30
Neeraj Gupta
2e25c38324 [mob] Store local assets state in db 2025-03-15 21:53:47 +05:30
Neeraj Gupta
7e691f84e4 Merge remote-tracking branch 'origin/main' into remote_db 2025-03-15 16:24:42 +05:30
Neeraj Gupta
d99593fc85 [mob] Fix impl diff compute & simplify 2025-03-14 18:30:11 +05:30
Neeraj Gupta
d4877ea446 [mob] Write in parallel 2025-03-14 17:05:16 +05:30
Neeraj Gupta
5f4c748886 [mob] Refactor & improve logs 2025-03-14 16:17:59 +05:30
Neeraj Gupta
eaab58c62a [mob] refactor 2025-03-14 13:48:34 +05:30
Neeraj Gupta
c1c020402e [mob] Simplify local import 2025-03-14 13:10:44 +05:30
Neeraj Gupta
028f4e61d2 [mob] Add initial db schema for local files 2025-03-13 17:19:47 +05:30
Neeraj Gupta
abc6f56247 [mob] Fix: Handle null nonce for shared collections 2025-03-13 17:12:26 +05:30
Neeraj Gupta
822eb59761 Merge branch 'main' into remote_db 2025-03-13 15:07:20 +05:30
Neeraj Gupta
fade7859ab Refactor 2025-03-13 11:34:33 +05:30
Neeraj Gupta
f85047fb28 [mob] Refactor 2025-03-13 11:21:50 +05:30
Neeraj Gupta
3f81c9beae [mob] Add helpers for local assets import 2025-03-13 09:55:53 +05:30
Neeraj Gupta
b7fa8d7c89 Merge branch 'main' into remote_db 2025-03-12 13:59:12 +05:30
Neeraj Gupta
5f75c5fc3f [mob] Add sql scripts for local db 2025-03-11 14:06:13 +05:30
Neeraj Gupta
98eaee3b9e Merge branch 'main' into remote_db 2025-03-11 13:35:36 +05:30
Neeraj Gupta
59bd039bed Add db schema for local files 2025-03-10 11:07:17 +05:30
Neeraj Gupta
3e90126a55 Merge branch 'main' into remote_db 2025-03-07 13:47:42 +05:30
Neeraj Gupta
dabcc0aeb5 Merge remote-tracking branch 'origin/main' into remote_db 2025-03-05 13:35:37 +05:30
Neeraj Gupta
c4b99af0e2 Merge branch 'main' into remote_db 2025-03-03 19:07:38 +05:30
Neeraj Gupta
679c12bb90 Remove recursion 2025-03-03 11:21:11 +05:30
Neeraj Gupta
3ef8ece8c0 Refactor 2025-03-03 11:16:43 +05:30
Neeraj Gupta
24f8cf188a Refactor 2025-03-03 10:34:38 +05:30
Neeraj Gupta
233838da3e Merge branch 'main' into remote_db 2025-03-01 12:37:42 +05:30
Neeraj Gupta
06c4866c75 [mob] Refactor 2025-02-28 14:07:01 +05:30
Neeraj Gupta
fa71acf91a Merge branch 'main' into remote_db 2025-02-28 13:51:53 +05:30
Neeraj Gupta
81d40826b3 Merge remote-tracking branch 'origin/main' into remote_db 2025-02-28 12:59:40 +05:30
Neeraj Gupta
2a14b5e5a3 clean up 2025-02-28 12:59:26 +05:30
Neeraj Gupta
63bbca09f3 [mob] clean up 2025-02-28 11:39:20 +05:30
Neeraj Gupta
cbff68bc42 [server] Persist remote pull response 2025-02-28 10:56:15 +05:30
Neeraj Gupta
070ab80be9 [mob] Enable foreign_keys on remote db 2025-02-27 14:12:26 +05:30
Neeraj Gupta
0efbf407d3 [mob] table for collection_files and files 2025-02-27 14:11:52 +05:30
Neeraj Gupta
ab22b28695 Merge branch 'main' into remote_db 2025-02-26 16:13:26 +05:30
Neeraj Gupta
d3466d7efe [mob] Insert collection_files 2025-02-25 05:30:46 +05:30
Neeraj Gupta
5945f2aaad Merge branch 'main' into remote_db 2025-02-24 15:11:30 +05:30
Neeraj Gupta
4c79b9cb92 [mob] Pull remote diff using new diff models 2025-02-20 11:57:36 +05:30
Neeraj Gupta
4676c363d2 [mob] Refactor 2025-02-20 11:55:46 +05:30
Neeraj Gupta
f75807d8f0 [mob] New diff model 2025-02-20 11:24:02 +05:30
Neeraj Gupta
578541308a [mob] Refactor 2025-02-19 15:51:41 +05:30
Neeraj Gupta
30cded4d3d [mob][crypto] Sync stream decryption 2025-02-19 15:51:13 +05:30
Neeraj Gupta
be2aee6baa Merge branch 'main' into remote_db 2025-02-18 15:21:25 +05:30
Neeraj Gupta
fae23df6eb [mob] refactor 2025-02-17 14:37:09 +05:30
Neeraj Gupta
2a1f2aded1 [mob] Use remote db for collections 2025-02-14 21:23:01 +05:30
Neeraj Gupta
6e85d24286 [mob] Add db row mappers for collection 2025-02-14 21:23:01 +05:30
Neeraj Gupta
0bcc676e44 [mob] Add new collection model for remote 2025-02-14 21:23:01 +05:30
Neeraj Gupta
b5a9bab5c6 refactor 2025-02-14 21:23:01 +05:30
Neeraj Gupta
5849d14cd9 refactor 2025-02-14 21:23:01 +05:30
Neeraj Gupta
77cde87927 [mob] Create db for collections & collection_files 2025-02-14 21:23:01 +05:30
Neeraj Gupta
670d6e8470 [mob] Scaffold remote db 2025-02-14 21:23:01 +05:30
394 changed files with 13110 additions and 22138 deletions

3
.gitmodules vendored
View File

@@ -9,6 +9,3 @@
[submodule "auth/assets/simple-icons"]
path = mobile/apps/auth/assets/simple-icons
url = https://github.com/simple-icons/simple-icons.git
[submodule "mobile/thirdparty/flutter"]
path = mobile/thirdparty/flutter
url = https://github.com/flutter/flutter.git

View File

@@ -48,11 +48,7 @@ See [docs/](docs/README.md) for how to edit these documents.
## Code contributions
If you'd like to contribute code, it is best to start small. Consider some well-scoped changes, say like adding more [custom icons to auth](mobile/apps/auth/docs/adding-icons.md), or fixing a specific bug. There is a (possibly outdated) list of tasks with the ["help wanted" or "good first issue"](<https://github.com/ente-io/ente/issues?q=state%3Aopen%20(label%3A%22good%20first%20issue%22%20OR%20label%3A%22help%20wanted%22%20)>) label too.
If you use any form of AI assistance, please include a co-author attribution in the commit for transparency.
In your PR, please include before / after screenshots, and clearly indicate the tests that you performed.
If you'd like to contribute code, it is best to start small. Consider some well-scoped changes, say like adding more [custom icons to auth](mobile/apps/auth/docs/adding-icons.md), or fixing a specific bug.
Code that changes the behaviour of the product might not get merged, at least not initially. The PR can serve as a discussion bed, but you might find it easier to just start a discussion instead, or post your perspective in the (likely) existing thread about the behaviour change or new feature you wish for.

View File

@@ -382,8 +382,7 @@ class _HomePageState extends State<HomePage> {
final bool shouldShowLockScreen =
await LockScreenSettings.instance.shouldShowLockScreen();
if (shouldShowLockScreen) {
// Manual lock: do not auto-prompt Touch ID; wait for user tap
await AppLock.of(context)!.showManualLockScreen();
await AppLock.of(context)!.showLockScreen();
} else {
await showDialogWidget(
context: context,

View File

@@ -1,204 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Philosophy
Ente is focused on privacy, transparency and trust. It's a fully open-source, end-to-end encrypted platform for storing data in the cloud. When contributing, always prioritize:
- User privacy and data security
- End-to-end encryption integrity
- Transparent, auditable code
- Zero-knowledge architecture principles
## Monorepo Context
This is the Ente Photos mobile app within the Ente monorepo. The monorepo contains:
- Mobile apps (Photos, Auth, Locker) at `mobile/apps/`
- Shared packages at `mobile/packages/`
- Web, desktop, CLI, and server components in parent directories
### Package Architecture
The Photos app uses two types of packages:
- **Shared packages** (`../../packages/`): Common code shared across multiple Ente apps (Photos, Auth, Locker)
- **Photos-specific plugins** (`./plugins/`): Custom Flutter plugins specific to Photos app for separation and testability
## Commit & PR Guidelines
⚠️ **CRITICAL: From the default template, use ONLY: Co-Authored-By: Claude <noreply@anthropic.com>** ⚠️
### Pre-commit/PR Checklist (RUN BEFORE EVERY COMMIT OR PR!)
**CRITICAL: CI will fail if ANY of these checks fail. Run ALL commands and ensure they ALL pass.**
```bash
# 1. Analyze flutter code for errors and warnings
flutter analyze
```
**Why CI might fail even after running these:**
- Skipping any command above
- Assuming auto-fix tools handle everything (they don't)
- Not fixing warnings that flutter reports
- Making changes after running the checks
### Commit & PR Message Rules
**These rules apply to BOTH commit messages AND pull request descriptions**
- Keep messages CONCISE (no walls of text)
- Subject line under 72 chars (no body text unless critical)
- NO emojis
- NO promotional text or links (except Co-Authored-By line)
### Additional Guidelines
- Check `git status` before committing to avoid adding temporary/binary files
- Never commit to main branch
- All CI checks must pass - run the checklist commands above before committing or creating PR
## Development Commands
### Using Melos (Monorepo Management)
```bash
# From mobile/ directory - bootstrap all packages
melos bootstrap
# Run Photos app specifically
melos run:photos:apk
# Build Photos APK
melos build:photos:apk
# Clean Photos app
melos clean:photos
```
### Direct Flutter Commands
```bash
# Development run with environment variables
./run.sh # Uses .env file with --flavor dev
# Development run without env file
flutter run -t lib/main.dart --flavor independent
# Build release APK
flutter build apk --release --flavor independent
# iOS build
cd ios && pod install && cd ..
flutter build ios
```
### Code Quality
```bash
# Static analysis and linting
flutter analyze .
# Run tests
flutter test
```
## Architecture Overview
### Service-Oriented Architecture
The app uses a service layer pattern with 28+ specialized services:
- **collections_service.dart**: Album and collection management
- **search_service.dart**: Search functionality with ML support
- **smart_memories_service.dart**: AI-powered memory curation
- **sync_service.dart**: Local/remote synchronization
- **Machine Learning Services**: Face recognition, semantic search, similar images
### Key Patterns
- **Service Locator**: Dependency injection via `lib/service_locator.dart`
- **Event Bus**: Loose coupling via `lib/core/event_bus.dart`
- **Repository Pattern**: Database abstraction in `lib/db/`
- **Rust Integration**: Performance-critical operations via Flutter Rust Bridge
### Security Architecture
- End-to-end encryption with `ente_crypto` package
- BIP39 mnemonic-based key generation (24 words)
- Secure storage using platform-specific implementations
- App lock and privacy screen features
## Project Structure
```
lib/
├── core/ # Configuration, constants, networking
├── services/ # Business logic (28+ services)
├── ui/ # UI components (18 subdirectories)
├── models/ # Data models (17 subdirectories)
├── db/ # SQLite database layer
├── utils/ # Utilities and helpers
├── gateways/ # API gateway interfaces
├── events/ # Event system
├── l10n/ # Localization files (intl_*.arb)
└── generated/ # Auto-generated code including localizations
```
## Localization (Flutter)
- Add new strings to `lib/l10n/intl_en.arb` (English base file)
- Use `AppLocalizations` to access localized strings in code
- Example: `AppLocalizations.of(context).yourStringKey`
- Run code generation after adding new strings: `flutter pub get`
- Translations managed via Crowdin for other languages
## Key Dependencies
- **Flutter 3.32.8** with Dart SDK >=3.3.0 <4.0.0
- **Media**: `photo_manager`, `video_editor`, `ffmpeg_kit_flutter`
- **Storage**: `sqlite_async`, `flutter_secure_storage`
- **ML/AI**: Custom ONNX runtime, `ml_linalg`
- **Rust**: Flutter Rust Bridge for performance
## Development Setup Requirements
1. Install Flutter v3.32.8 and Rust
2. Install Flutter Rust Bridge: `cargo install flutter_rust_bridge_codegen`
3. Generate Rust bindings: `flutter_rust_bridge_codegen generate`
4. Update submodules: `git submodule update --init --recursive`
5. Enable git hooks: `git config core.hooksPath hooks`
## Critical Coding Requirements
### 1. Code Quality - MANDATORY
**Every code change MUST pass `flutter analyze` with zero issues**
- Run `flutter analyze` after EVERY code modification
- Resolve ALL issues (info, warning, error) - no exceptions
- The codebase has zero issues by default, so any issue is from your changes
- DO NOT commit or consider work complete until `flutter analyze` passes cleanly
### 2. Component Reuse - MANDATORY
**Always try to reuse existing components**
- Use a subagent to search for existing components before creating new ones
- Only create new components if none exist that meet the requirements
- Check both UI components in `lib/ui/` and shared components in `../../packages/`
### 3. Design System - MANDATORY
**Never hardcode colors or text styles**
- Always use the Ente design system for colors and typography
- Use a subagent to find the appropriate design tokens
- Access colors via theme: `getEnteColorScheme(context)`
- Access text styles via theme: `getEnteTextTheme(context)`
- Call above theme getters only at the top of (`build`) methods and re-use them throughout the component
- If you MUST use custom colors/styles (extremely rare), explicitly inform the user with a clear warning
### 4. Documentation Sync - MANDATORY
**Keep spec documents synchronized with code changes**
- When modifying code, also update any associated spec documents
- Check for related spec files in `docs/` or project directories
- Ensure documentation reflects the current implementation
- Update examples in specs if behavior changes
## Important Notes
- Large service files (some 70k+ lines) - consider file context when editing
- 400+ dependencies - check existing libraries before adding new ones
- When adding functionality, check both `../../packages/` for shared code and `./plugins/` for Photos-specific plugins
- Performance-critical paths use Rust integration
- Always follow existing code conventions and patterns in neighboring files
# Individual Preferences
- @~/.claude/my-project-instructions.md

View File

@@ -1,9 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="io.ente.photos">
<application
tools:replace="android:label"
android:name="${applicationName}"
<application android:name="${applicationName}"
android:label="@string/app_name"
android:icon="@mipmap/icon_green"
android:usesCleartextTraffic="true"

View File

@@ -1,49 +1,36 @@
Store, share and discover your memories with Ente Photos. With end-to-end encryption, only you—and those you share with—can see your photos and videos. Ente Photos has lovingly protected over 200 million memories for people who trust us across all major platforms. Get started with 10 GB free.
Ente is a simple app to backup and share your photos and videos.
Why Ente Photos?
If you've been looking for a privacy-friendly alternative to Google Photos, you've come to the right place. With Ente, they are stored end-to-end encrypted (e2ee). This means that only you can view them.
Ente Photos is designed for those who truly value their memories. With end-to-end encryption and secure backups in three locations, your photos stay truly private and safe. Powerful on-device AI helps you find faces and objects instantly, while curated stories bring cherished memories to the present. Share encrypted albums with loved ones, invite family at no extra cost, and lock sensitive images with a password. Available on mobile, desktop, and web, Ente preserves every pixel of your photos and videos.
We have open-source apps across Android, iOS, web and desktop, and your photos will seamlessly sync between all of them in an end-to-end encrypted (e2ee) manner.
Features:
Ente also makes it simple to share your albums with your loved ones, even if they aren't on Ente. You can share publicly viewable links, where they can view your album and collaborate by adding photos to it, even without an account or app.
END-TO-END ENCRYPTED STORAGE: Your photos and videos are encrypted on your device, and then automatically backed up to the cloud.
Your encrypted data is replicated to 3 different locations, including a fall-out shelter in Paris. We take posterity seriously and make it easy to ensure that your memories outlive you.
SHARE AND COLLABORATE: Let your family or friends add photos and videos to your albums. Everything, end-to-end encrypted.
We are here to make the safest photos app ever, come join our journey!
RELIVE YOUR MEMORIES: Through the stories Ente curates for you, relive your memories from previous years. Easily spread the cheer by sharing them with your loved ones or friends.
FEATURES
- Original quality backups, because every pixel is important
- Family plans, so you can share storage with your family
- Collaborative albums, so you can pool together photos after a trip
- Shared folders, in case you want your partner to enjoy your "Camera" clicks
- Album links, that can be protected with a password
- Ability to free up space, by removing files that have been safely backed up
- Human support, because you're worth it
- Descriptions, so you can caption your memories and find them easily
- Image editor, to add finishing touches
- Favorite, hide and relive your memories, for they are precious
- One-click import from Google, Apple, your hard drive and more
- Dark theme, because your photos look good in it
- 2FA, 3FA, biometric auth
- and a LOT more!
SEARCH FOR ANYONE AND ANYTHING: Using on-device AI, Ente helps you find faces and key elements in a photo, so you can search through your entire library using natural language search.
PERMISSIONS
Ente requests for certain permissions to serve the purpose of a photo storage provider, which can be reviewed here: https://github.com/ente-io/ente/blob/f-droid/mobile/android/permissions.md
INVITE YOUR FAMILY: Invite up to 5 family members to any paid plan at no extra cost. Only your storage space is shared, not your data. Each member will receive their own private space.
PRICING
We don't offer forever free plans, because it is important to us that we remain sustainable and withstand the test of time. Instead we offer affordable plans that you can freely share with your family. You can find more information at ente.io.
AVAILABLE EVERYWHERE: Ente Photos is available on iOS, Android, Windows, Mac, Linux and the web, so you can access your photos and videos from any device you have.
NEVER LOSE YOUR PHOTOS: Ente stores your encrypted backups in 3 secure locations—including an underground facility—so your photos stay safe, no matter what.
EASY IMPORT: Use our powerful desktop app to import data from other providers. If you need any help moving, reach out, and we'll be there.
ORIGINAL QUALITY BACKUPS: All photos and videos are stored in their original quality, including the metadata, without any compression or loss in quality.
APP LOCK: Make sure no one else can see your photos and videos using the built in App Lock. You can set a pin, or use biometrics to lock the app only for yourself.
HIDDEN PHOTOS: Hide your most private photos and videos to the Hidden folder, which is password protected by default.
FREE DEVICE SPACE: Free up your device's space by clearing files that have already been backed, in a single click.
COLLECT PHOTOS: Went to a party and want to collect all the photos in one place? Just share a link with your friends and ask them to upload.
PARTNER SHARING: Share your camera album with your partner so they can automatically see your photos on their device.
LEGACY: Allow trusted contacts to access your account in your absence.
DARK & LIGHT THEMES: Choose the mode that will make your photos pop.
ADDITIONAL SECURITY: Turn on two-factor authentication or set a lock-screen for the app.
OPEN-SOURCE AND AUDITED: Ente Photoss code is open-source, and has been audited by third-party security experts.
HUMAN SUPPORT: We take pride in providing real human support. If you need help, reach out to support@ente.io, and one of us will be there to assist you.
Keep your memories safe and private, with Ente Photos. Get started with 10 GB free.
Visit ente.io to learn more.
SUPPORT
We take pride in offering human support. If you are our paid customer, you can reach out to team@ente.io and expect a response from our team within 24 hours.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 584 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 522 KiB

After

Width:  |  Height:  |  Size: 690 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 662 KiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 521 KiB

After

Width:  |  Height:  |  Size: 853 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -1 +1 @@
Backup, Organise, Share - Private photo storage with end-to-end encryption
Ente Photos is an open source photos app, that provides end-to-end encrypted backups for your photos and videos.

View File

@@ -1 +1 @@
Ente Photos - Encrypted photo storage
Ente Photos - Open source, end-to-end encrypted alternative to Google Photos

View File

@@ -8,10 +8,10 @@ allprojects {
google()
jcenter()
mavenCentral()
mavenLocal() // for FDroid
// maven {
// url "${project(':ffmpeg_kit_flutter').projectDir}/libs"
// }
// mavenLocal() // for FDroid
maven {
url "${project(':ffmpeg_kit_flutter').projectDir}/libs"
}
}
}

View File

@@ -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:

View File

@@ -1,49 +1,36 @@
Store, share and discover your memories with Ente Photos. With end-to-end encryption, only you—and those you share with—can see your photos and videos. Ente Photos has lovingly protected over 165 million memories for people who trust us across all major platforms. Get started with 10 GB free.
ente is a simple app to backup and share your photos and videos.
Why Ente Photos?
If you've been looking for a privacy-friendly alternative to Google Photos, you've come to the right place. With ente, they are stored end-to-end encrypted (e2ee). This means that only you can view them.
Ente Photos is designed for those who truly value their memories. With end-to-end encryption and secure backups in three locations, your photos stay truly private and safe. Powerful on-device AI helps you find faces and objects instantly, while curated stories bring cherished memories to the present. Share encrypted albums with loved ones, invite family at no extra cost, and lock sensitive images with a password. Available on mobile, desktop, and web, Ente preserves every pixel of your photos and videos.
We have open-source apps across Android, iOS, web and desktop, and your photos will seamlessly sync between all of them in an end-to-end encrypted (e2ee) manner.
Features:
ente also makes it simple to share your albums with your loved ones, even if they aren't on ente. You can share publicly viewable links, where they can view your album and collaborate by adding photos to it, even without an account or app.
END-TO-END ENCRYPTED STORAGE: Your photos and videos are encrypted on your device, and then automatically backed up to the cloud.
Your encrypted data is replicated to 3 different locations, including a fall-out shelter in Paris. We take posterity seriously and make it easy to ensure that your memories outlive you.
SHARE AND COLLABORATE: Let your family or friends add photos and videos to your albums. Everything, end-to-end encrypted.
We are here to make the safest photos app ever, come join our journey!
RELIVE YOUR MEMORIES: Through the stories Ente curates for you, relive your memories from previous years. Easily spread the cheer by sharing them with your loved ones or friends.
FEATURES
- Original quality backups, because every pixel is important
- Family plans, so you can share storage with your family
- Collaborative albums, so you can pool together photos after a trip
- Shared folders, in case you want your partner to enjoy your "Camera" clicks
- Album links, that can be protected with a password
- Ability to free up space, by removing files that have been safely backed up
- Human support, because you're worth it
- Descriptions, so you can caption your memories and find them easily
- Image editor, to add finishing touches
- Favorite, hide and relive your memories, for they are precious
- One-click import from Google, Apple, your hard drive and more
- Dark theme, because your photos look good in it
- 2FA, 3FA, biometric auth
- and a LOT more!
SEARCH FOR ANYONE AND ANYTHING: Using on-device AI, Ente helps you find faces and key elements in a photo, so you can search through your entire library using natural language search.
PERMISSIONS
ente requests for certain permissions to serve the purpose of a photo storage provider, which can be reviewed here: https://github.com/ente-io/ente/blob/f-droid/mobile/android/permissions.md
INVITE YOUR FAMILY: Invite up to 5 family members to any paid plan at no extra cost. Only your storage space is shared, not your data. Each member will receive their own private space.
PRICING
We don't offer forever free plans, because it is important to us that we remain sustainable and withstand the test of time. Instead we offer affordable plans that you can freely share with your family. You can find more information at ente.io.
AVAILABLE EVERYWHERE: Ente Photos is available on iOS, Android, Windows, Mac, Linux and the web, so you can access your photos and videos from any device you have.
NEVER LOSE YOUR PHOTOS: Ente stores your encrypted backups in 3 secure locations—including an underground facility—so your photos stay safe, no matter what.
EASY IMPORT: Use our powerful desktop app to import data from other providers. If you need any help moving, reach out, and we'll be there.
ORIGINAL QUALITY BACKUPS: All photos and videos are stored in their original quality, including the metadata, without any compression or loss in quality.
APP LOCK: Make sure no one else can see your photos and videos using the built in App Lock. You can set a pin, or use biometrics to lock the app only for yourself.
HIDDEN PHOTOS: Hide your most private photos and videos to the Hidden folder, which is password protected by default.
FREE DEVICE SPACE: Free up your device's space by clearing files that have already been backed, in a single click.
COLLECT PHOTOS: Went to a party and want to collect all the photos in one place? Just share a link with your friends and ask them to upload.
PARTNER SHARING: Share your camera album with your partner so they can automatically see your photos on their device.
LEGACY: Allow trusted contacts to access your account in your absence.
DARK & LIGHT THEMES: Choose the mode that will make your photos pop.
ADDITIONAL SECURITY: Turn on two-factor authentication or set a lock-screen for the app.
OPEN-SOURCE AND AUDITED: Ente Photoss code is open-source, and has been audited by third-party security experts.
HUMAN SUPPORT: We take pride in providing real human support. If you need help, reach out to support@ente.io, and one of us will be there to assist you.
Keep your memories safe and private, with Ente Photos. Get started with 10 GB free.
Visit ente.io to learn more.
SUPPORT
We take pride in offering human support. If you are our paid customer, you can reach out to team@ente.io and expect a response from our team within 24 hours.

View File

@@ -1 +1 @@
Backup, Organise, Share - Private photo storage with end-to-end encryption
ente is an end-to-end encrypted photo storage app

View File

@@ -1 +1 @@
Ente Photos - Encrypted photo storage
ente - encrypted photo storage

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 584 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 522 KiB

After

Width:  |  Height:  |  Size: 690 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 662 KiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 521 KiB

After

Width:  |  Height:  |  Size: 853 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -29,6 +29,10 @@ class LRUMap<K, V> {
}
}
bool containsKey(K key) {
return _map.containsKey(key);
}
void remove(K key) {
_map.remove(key);
}

View File

@@ -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(),
);
}
}

View File

@@ -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

View File

@@ -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 = ':';

View File

@@ -1,13 +1,13 @@
// Common runtime exceptions that can occur during normal app operation.
// These are recoverable conditions that should be caught and handled.
/// Common runtime exceptions that can occur during normal app operation.
/// These are recoverable conditions that should be caught and handled.
class WidgetUnmountedException implements Exception {
final String? message;
WidgetUnmountedException([this.message]);
@override
String toString() => message != null
? 'WidgetUnmountedException: $message'
String toString() => message != null
? 'WidgetUnmountedException: $message'
: 'WidgetUnmountedException';
}
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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

View 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);
}
}

View 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,
),
);
}
}

View 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)",
);
}
}
}

View 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;
}
}

View 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;
}
}

View 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(),
);
},
);
}
}

View 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',
);
}
}

View File

@@ -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();
}

View File

@@ -39,26 +39,10 @@ class ClipVectorDB {
final documentsDirectory = await getApplicationDocumentsDirectory();
final String dbPath = join(documentsDirectory.path, _databaseName);
_logger.info("Opening vectorDB access: DB path " + dbPath);
late VectorDb vectorDB;
try {
vectorDB = VectorDb(
filePath: dbPath,
dimensions: _embeddingDimension,
);
} catch (e, s) {
_logger.severe("Could not open VectorDB at path $dbPath", e, s);
_logger.severe("Deleting the index file and trying again");
await deleteIndexFile();
try {
vectorDB = VectorDb(
filePath: dbPath,
dimensions: _embeddingDimension,
);
} catch (e, s) {
_logger.severe("Still can't open VectorDB at path $dbPath", e, s);
rethrow;
}
}
final vectorDB = VectorDb(
filePath: dbPath,
dimensions: _embeddingDimension,
);
final stats = await getIndexStats(vectorDB);
_logger.info("VectorDB connection opened with stats: ${stats.toString()}");
@@ -88,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) {

View File

@@ -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,
);
}

View File

@@ -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,

File diff suppressed because it is too large Load Diff

View File

@@ -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,

View 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;
}
}

View 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;
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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();
}
}

View 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');
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View 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}';
}

View File

@@ -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]";
}
}

View 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;
}
}

View 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,
});
}

View File

@@ -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}",
@@ -1776,6 +1776,11 @@
"same": "نفس",
"different": "مختلف",
"sameperson": "نفس الشخص؟",
"cLTitle1": "محرر الصور المتقدم",
"cLDesc1": "نحن بصدد إطلاق محرر صور جديد ومتقدم يضيف المزيد من إطارات الاقتصاص، والإعدادات المسبقة للفلاتر من أجل تعديلات سريعة، وخيارات الضبط الدقيق التي تشمل التشبع، والتباين، والسطوع، ودرجة الحرارة، وغير ذلك الكثير. يتضمن المحرر الجديد أيضا القدرة على الرسم على صورك وإضافة الرموز التعبيرية كملصقات.",
"cLTitle2": "ألبومات ذكية",
"cLTitle3": "معرض محسن",
"cLTitle4": "تمرير أسرع",
"thisWeek": "هذا الأسبوع",
"lastWeek": "الأسبوع الماضي",
"thisMonth": "هذا الشهر",

View File

@@ -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}",
@@ -1776,6 +1776,14 @@
"same": "Stejné",
"different": "Odlišné",
"sameperson": "Stejná osoba?",
"cLTitle1": "Pokročilý editor obrázků",
"cLDesc1": "Vydáváme nový a pokročilý editor obrázků, který přidává více ořezových rámečků, přednastavené filtry pro rychlé úpravy, možnosti jemného doladění včetně sytosti, kontrastu, jasu, teploty a mnoho dalšího. Nový editor také zahrnuje možnost kreslit na vaše fotografie a přidávat emodži jako nálepky.",
"cLTitle2": "Chytrá alba",
"cLDesc2": "Nyní můžete automaticky přidávat fotografie vybraných osob do libovolného alba. Stačí přejít do alba a v rozbalovací nabídce vybrat možnost „Automaticky přidat osoby“. Pokud tuto funkci použijete společně se sdíleným albem, můžete sdílet fotografie bez jediného kliknutí.",
"cLTitle3": "Vylepšená galerie",
"cLDesc3": "Přidali jsme možnost seskupit vaši galerii podle týdnů, měsíců a let. Nyní můžete svou galerii přizpůsobit tak, aby vypadala přesně podle vašich představ, a to díky těmto novým možnostem seskupování a přizpůsobitelným mřížkám",
"cLTitle4": "Rychlejší posouvání",
"cLDesc4": "Kromě řady vylepšení pod kapotou, která zlepšují procházení galerií, jsme také přepracovali posuvník tak, aby zobrazoval značky, díky nimž můžete rychle přeskakovat po časové ose.",
"indexingPausedStatusDescription": "Indexování je pozastaveno. Automaticky se obnoví, jakmile bude zařízení připraveno. Zařízení je považováno za připravené, pokud jsou úroveň nabití baterie, stav baterie a teplotní stav v normálním rozmezí.",
"thisWeek": "Tento týden",
"lastWeek": "Minulý týden",
@@ -1836,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": {

View File

@@ -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",

View File

@@ -1776,6 +1776,14 @@
"same": "Gleich",
"different": "Verschieden",
"sameperson": "Dieselbe Person?",
"cLTitle1": "Erweiterte Bildbearbeitung",
"cLDesc1": "Wir veröffentlichen eine neue und erweiterte Bildbearbeitung, die mehr Bildzuschnitte ermöglicht, vordefinierte Filter für schnelleres Bearbeiten bietet, sowie die Feinabstimmung von Sättigung, Kontrast, Helligkeit und vielem mehr erlaubt. Der neue Editor erlaubt außerdem das Zeichnen auf den Fotos oder das Hinzufügen von Emojis als Sticker.",
"cLTitle2": "Intelligente Alben",
"cLDesc2": "Du kannst jetzt automatisch Fotos von ausgewählten Personen zu jedem Album hinzufügen. Öffne einfach das Album und wähle \"Personen automatisch hinzufügen\" aus dem Menü. Zusammen mit einem geteilten Album kannst Du Fotos mit null Klicks teilen.",
"cLTitle3": "Verbesserte Galerie",
"cLDesc3": "Wir haben die Möglichkeit hinzugefügt, Alben nach Wochen, Monaten und Jahren zu gruppieren. Du kannst jetzt die Galerie mit diesen neuen Gruppierungs-Optionen so anpassen, dass sie genau so aussieht, wie Du möchtest, zusammen mit angepassten Rastern",
"cLTitle4": "Schnelleres Scrollen",
"cLDesc4": "Zusammen mit einem Schwung Änderungen unter der Haube, um das Erlebnis beim Scrollen der Galerie zu verbessern, haben wir außerdem den Scrollbalken mit Markern neu gestaltet, um es Dir zu ermöglichen, schnell in der Zeitleiste zu springen.",
"indexingPausedStatusDescription": "Die Indizierung ist pausiert. Sie wird automatisch fortgesetzt, wenn das Gerät bereit ist. Das Gerät wird als bereit angesehen, wenn sich der Akkustand, die Akkugesundheit und der thermische Zustand in einem gesunden Bereich befinden.",
"thisWeek": "Diese Woche",
"lastWeek": "Letzte Woche",
@@ -1925,11 +1933,5 @@
"nothingHereTryAnotherFilter": "Nichts zu sehen, probier einen anderen Filter! 👀",
"related": "Verwandt",
"hoorayyyy": "Hurraaaa!",
"nothingToTidyUpHere": "Hier gibt es nichts zu bereinigen",
"cLTitle1": "Ähnliche Bilder",
"cLDesc1": "Wir führen ein neues ML-basiertes System ein, um ähnliche Bilder zu erkennen, mit dem du deine Bibliothek bereinigen kannst. Verfügbar unter Einstellungen -> Sicherung -> Speicherplatz freigeben",
"cLTitle2": "Video-Streaming-Verbesserungen",
"cLDesc2": "Du kannst jetzt die Stream-Generierung für Videos direkt aus der App manuell auslösen. Wir haben auch einen neuen Video-Streaming-Einstellungsbildschirm hinzugefügt, der dir zeigt, welcher Prozentsatz deiner Videos für das Streaming verarbeitet wurde",
"cLTitle3": "Leistungsverbesserungen",
"cLDesc3": "Mehrere Verbesserungen im Hintergrund, einschließlich besserer Cache-Nutzung und einer flüssigeren Scroll-Erfahrung"
"nothingToTidyUpHere": "Hier gibt es nichts zu bereinigen"
}

View File

@@ -1776,6 +1776,14 @@
"same": "Same",
"different": "Different",
"sameperson": "Same person?",
"cLTitle1": "Advanced Image Editor",
"cLDesc1": "We are releasing a new and advanced image editor that add more cropping frames, filter presets for quick edits, fine tuning options including saturation, contrast, brightness, temperature and a lot more. The new editor also includes the ability to draw on your photos and add emojis as stickers.",
"cLTitle2": "Smart Albums",
"cLDesc2": "You can now automatically add photos of selected people to any album. Just go the album, and select \"auto-add people\" from the overflow menu. If used along with shared album, you can share photos with zero clicks.",
"cLTitle3": "Improved Gallery",
"cLDesc3": "We have added the ability to group your gallery by weeks, months, and years. You can now customise your gallery to look exactly the way you want with these new grouping options, along with custom grids",
"cLTitle4": "Faster Scroll",
"cLDesc4": "Along with a bunch of under the hood improvements to improve the gallery scroll experience, we have also redesigned the scroll bar to show markers, allowing you to quickly jump across the timeline.",
"indexingPausedStatusDescription": "Indexing is paused. It will automatically resume when the device is ready. The device is considered ready when its battery level, battery health, and thermal status are within a healthy range.",
"thisWeek": "This week",
"lastWeek": "Last week",
@@ -1931,11 +1939,5 @@
"related": "Related",
"hoorayyyy": "Hoorayyyy!",
"nothingToTidyUpHere": "Nothing to tidy up here",
"deletingDash": "Deleting - ",
"cLTitle1": "Similar images",
"cLDesc1": "We are introducing a new ML-based system to detect similar images, using which you can cleanup your library. Available in Settings -> Backup -> Free up space",
"cLTitle2": "Video streaming enhancements",
"cLDesc2": "You can now manually trigger stream generation for videos directly from the app. We have also added a new video streaming settings screen which will show you what percentage of your videos have been processed for streaming",
"cLTitle3": "Performance improvements",
"cLDesc3": "Multiple under the hood improvements, including better cache usage and a smoother scroll experience"
"deletingDash": "Deleting - "
}

View File

@@ -1776,6 +1776,14 @@
"same": "Igual",
"different": "Diferente",
"sameperson": "la misma persona?",
"cLTitle1": "Editor avanzado de imágenes",
"cLDesc1": "Estamos lanzando un nuevo y avanzado editor de imágenes que añade más marcos de recorte, preajustes de filtros para edición rápida, opciones de ajuste finas incluyendo saturación, contraste, brillo, temperatura y mucho más. El nuevo editor también incluye la capacidad de dibujar en tus fotos y añadir emojis como pegatinas.",
"cLTitle2": "Álbumes Inteligentes",
"cLDesc2": "Ahora puedes añadir automáticamente fotos de personas seleccionadas a cualquier álbum. Solo tienes que ir al álbum, y seleccionar \"Agregar personas automáticamente\" del menú desbordante. Si se utiliza junto con el álbum compartido, puedes compartir fotos con cero clics.",
"cLTitle3": "Galería mejorada",
"cLDesc3": "Hemos añadido la capacidad de agrupar tu galería por semanas, meses y años. Ahora puedes personalizar tu galería exactamente como quieras con estas nuevas opciones de agrupación, junto con rejillas personalizadas",
"cLTitle4": "Desplazamiento más rápido",
"cLDesc4": "Junto con un montón de mejoras bajo el capó para mejorar la experiencia del desplazamiento de la galería también hemos rediseñado la barra de desplazamiento para mostrar los marcadores, permitiéndote saltar rápidamente a través de la línea de tiempo.",
"indexingPausedStatusDescription": "La indexación está pausada. Se reanudará automáticamente cuando el dispositivo esté listo. El dispositivo se considera listo cuando su nivel de batería, la salud de la batería y temperatura están en un rango saludable.",
"thisWeek": "Esta semana",
"lastWeek": "Semana pasada",
@@ -1811,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": {
@@ -1838,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": {
@@ -1925,11 +1933,5 @@
"nothingHereTryAnotherFilter": "Nada aquí, ¡prueba con otro filtro! 👀",
"related": "Relacionado",
"hoorayyyy": "¡Hurraaaa!",
"nothingToTidyUpHere": "Nada que limpiar aquí",
"cLTitle1": "Imágenes similares",
"cLDesc1": "Estamos introduciendo un nuevo sistema basado en ML para detectar imágenes similares, con el cual puedes limpiar tu biblioteca. Disponible en Configuración -> Copia de seguridad -> Liberar espacio",
"cLTitle2": "Mejoras de transmisión de video",
"cLDesc2": "Ahora puedes activar manualmente la generación de transmisión para videos directamente desde la aplicación. También hemos agregado una nueva pantalla de configuración de transmisión de video que te mostrará qué porcentaje de tus videos han sido procesados para transmisión",
"cLTitle3": "Mejoras de rendimiento",
"cLDesc3": "Múltiples mejoras internas, incluyendo mejor uso de caché y una experiencia de desplazamiento más fluida"
}
"nothingToTidyUpHere": "Nada que limpiar aquí"
}

View File

@@ -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": {
@@ -1776,6 +1776,14 @@
"same": "Identique",
"different": "Différent(e)",
"sameperson": "Même personne ?",
"cLTitle1": "Éditeur d'image avancé",
"cLDesc1": "Nous déployons un nouvel éditeur d'image avancé qui ajoute plus d'options de rognage, des filtres, des préréglages pour des modifications rapides ainsi que des options de réglage fin (la saturation, le contraste, la luminosité, la température et beaucoup plus). Le nouvel éditeur inclut également la possibilité de dessiner sur vos photos et d'ajouter des emojis en tant qu'autocollants.",
"cLTitle2": "Albums Intelligents",
"cLDesc2": "Vous pouvez maintenant ajouter automatiquement des photos de personnes sélectionnées à n'importe quel album. Allez simplement à l'album et sélectionnez \"Ajouter automatiquement des personnes\" dans le menu déroulant. Couplé avec un album partagé, vous pouvez partager des photos en zéro clic.",
"cLTitle3": "Galerie améliorée",
"cLDesc3": "Nous avons ajouté la possibilité de regrouper votre galerie par semaines, mois et années. Vous pouvez maintenant personnaliser votre galerie pour qu'elle soit exactement comme vous le souhaitez avec ces nouvelles options de regroupement, ainsi que des grilles personnalisées",
"cLTitle4": "Défilement plus rapide",
"cLDesc4": "En plus des quelques améliorations pour améliorer l'expérience de défilement de la galerie, nous avons également redessiné la barre de défilement pour afficher des marqueurs, ce qui vous permet de sauter rapidement dans la chronologie.",
"indexingPausedStatusDescription": "L'indexation est en pause. Elle reprendra automatiquement lorsque l'appareil sera prêt. Celui-ci est considéré comme prêt lorsque le niveau de batterie, sa santé et son état thermique sont dans une plage saine.",
"thisWeek": "Cette semaine",
"lastWeek": "La semaine dernière",
@@ -1811,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": {
@@ -1917,11 +1925,5 @@
"nothingHereTryAnotherFilter": "Rien ici, essayez un autre filtre ! 👀",
"related": "Liés",
"hoorayyyy": "Houraaa !",
"nothingToTidyUpHere": "Rien à nettoyer ici",
"cLTitle1": "Images similaires",
"cLDesc1": "Nous introduisons un nouveau système basé sur l'IA pour détecter les images similaires, avec lequel vous pouvez nettoyer votre bibliothèque. Disponible dans Paramètres -> Sauvegarde -> Libérer de l'espace",
"cLTitle2": "Améliorations de la diffusion vidéo",
"cLDesc2": "Vous pouvez maintenant déclencher manuellement la génération de flux pour les vidéos directement depuis l'application. Nous avons également ajouté un nouvel écran de paramètres de diffusion vidéo qui vous montrera quel pourcentage de vos vidéos ont été traitées pour la diffusion",
"cLTitle3": "Améliorations des performances",
"cLDesc3": "Plusieurs améliorations internes, incluant une meilleure utilisation du cache et une expérience de défilement plus fluide"
}
"nothingToTidyUpHere": "Rien à nettoyer ici"
}

View File

@@ -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": "מחביא..."
}
}

View File

@@ -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."
}
}

View File

@@ -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"
}
}

View File

@@ -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}",
@@ -1745,11 +1745,5 @@
"birthdayNotifications": "Notifiche dei compleanni",
"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",
"cLTitle1": "Immagini simili",
"cLDesc1": "Stiamo introducendo un nuovo sistema basato su ML per rilevare immagini simili, con il quale puoi pulire la tua libreria. Disponibile in Impostazioni -> Backup -> Libera spazio",
"cLTitle2": "Miglioramenti streaming video",
"cLDesc2": "Ora puoi attivare manualmente la generazione di stream per i video direttamente dall'app. Abbiamo anche aggiunto una nuova schermata delle impostazioni di streaming video che ti mostrerà quale percentuale dei tuoi video è stata elaborata per lo streaming",
"cLTitle3": "Miglioramenti delle prestazioni",
"cLDesc3": "Multipli miglioramenti interni, incluso un miglior utilizzo della cache e un'esperienza di scorrimento più fluida"
}
"birthdays": "Compleanni"
}

View File

@@ -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": {
@@ -1665,11 +1665,5 @@
"moon": "月明かりの中",
"onTheRoad": "再び道で",
"food": "料理を楽しむ",
"pets": "毛むくじゃらな仲間たち",
"cLTitle1": "類似画像",
"cLDesc1": "類似画像を検出する新しいML基盤システムを導入し、ライブラリをクリーンアップできます。設定 -> バックアップ -> 容量を空ける で利用可能",
"cLTitle2": "動画ストリーミングの強化",
"cLDesc2": "アプリから直接、動画のストリーム生成を手動でトリガーできるようになりました。また、動画のうち何パーセントがストリーミング用に処理されたかを表示する新しい動画ストリーミング設定画面も追加しました",
"cLTitle3": "パフォーマンスの改善",
"cLDesc3": "より良いキャッシュ使用とよりスムーズなスクロール体験を含む、複数の内部改善"
}
"pets": "毛むくじゃらな仲間たち"
}

View File

@@ -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}",
@@ -1776,6 +1762,14 @@
"same": "Tas pats",
"different": "Skirtingas",
"sameperson": "Tas pats asmuo?",
"cLTitle1": "Pažangi vaizdų rengyklė",
"cLDesc1": "Mes išleidžiame naują ir pažangią vaizdų rengyklę, kurioje yra daugiau apkirpimo rėmelių, filtro nustatymų sparčiams redagavimams, tikslaus sureguliavimo parinkčių, įskaitant sodrumą, kontrastą, skaistį, temperatūrą ir daug daugiau. Naujoji rengyklė taip pat suteikia galimybę piešti ant nuotraukų ir pridėti jaustukus kaip lipdukus.",
"cLTitle2": "Išmanieji albumai",
"cLDesc2": "Dabar galite automatiškai įtraukti pasirinktų asmenų nuotraukas į bet kurį albumą. Tiesiog eikite į albumą ir iš išskleidžiamojo meniu pasirinkite „Automatiškai įtraukti asmenis“. Jei naudojama kartu su bendrinimu albumu, nuotraukas galite bendrinti be jokių paspaudimų.",
"cLTitle3": "Patobulinta galerija",
"cLDesc3": "Pridėjome galimybę sugrupuoti galeriją pagal savaites, mėnesius ir metus. Dabar galite pritaikyti galeriją taip, kad ji atrodytų būtent taip, kaip norite su šiomis naujomis grupavimo parinktimis ir pasirinktiniais tinkleliais.",
"cLTitle4": "Spartesnis slinkimas",
"cLDesc4": "Kartu su daugybe vidinių patobulinimų pagerinti galerijos slinkimo patirtį, mes taip pat pertvarkėme slinkties juostą, kad joje būtų rodomi žymekliai, leidžiantys sparčiai pereiti per laiko juostą.",
"indexingPausedStatusDescription": "Indeksavimas pristabdytas. Jis bus automatiškai tęsiamas, kai įrenginys bus parengtas. Įrenginys laikomas parengtu, kai jo akumuliatoriaus įkrovos lygis, akumuliatoriaus būklė ir terminė būklė yra normos ribose.",
"thisWeek": "Šią savaitę",
"lastWeek": "Praėjusią savaitę",

View File

@@ -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",
@@ -1772,11 +1772,5 @@
"thePersonWillNotBeDisplayed": "De persoon wordt niet meer getoond in de personen sectie. Foto's blijven ongemoeid.",
"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",
"cLTitle1": "Vergelijkbare afbeeldingen",
"cLDesc1": "We introduceren een nieuw ML-gebaseerd systeem om vergelijkbare afbeeldingen te detecteren, waarmee je je bibliotheek kunt opschonen. Beschikbaar in Instellingen -> Backup -> Ruimte vrijmaken",
"cLTitle2": "Video streaming verbeteringen",
"cLDesc2": "Je kunt nu handmatig stream generatie voor video's activeren direct vanuit de app. We hebben ook een nieuw video streaming instellingenscherm toegevoegd dat toont welk percentage van je video's is verwerkt voor streaming",
"cLTitle3": "Prestatieverbeteringen",
"cLDesc3": "Meerdere verbeteringen onder de motorkap, inclusief beter cache gebruik en een vloeiendere scroll ervaring"
}
"yesIgnore": "Ja, negeer"
}

View File

@@ -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"
}
}

View File

@@ -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",
@@ -1736,11 +1736,5 @@
"albumsWidgetDesc": "Velg albumene du ønsker å se på din hjemskjerm.",
"memoriesWidgetDesc": "Velg typen minner du ønsker å se på din hjemskjerm.",
"smartMemories": "Smarte minner",
"pastYearsMemories": "Tidligere års minner",
"cLTitle1": "Lignende bilder",
"cLDesc1": "Vi introduserer et nytt ML-basert system for å oppdage lignende bilder, som du kan bruke til å rydde opp i biblioteket ditt. Tilgjengelig i Innstillinger -> Sikkerhetskopi -> Frigjør plass",
"cLTitle2": "Video streaming forbedringer",
"cLDesc2": "Du kan nå manuelt utløse stream generering for videoer direkte fra appen. Vi har også lagt til en ny video streaming innstillinger skjerm som viser deg hvor mange prosent av videoene dine som er behandlet for streaming",
"cLTitle3": "Ytelsesforbedringer",
"cLDesc3": "Flere forbedringer under panseret, inkludert bedre cache bruk og en jevnere rullingsopplevelse"
}
"pastYearsMemories": "Tidligere års minner"
}

View File

@@ -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}",
@@ -1776,6 +1776,14 @@
"same": "Identyczne",
"different": "Inne",
"sameperson": "Ta sama osoba?",
"cLTitle1": "Zaawansowany Edytor Obrazów",
"cLDesc1": "Wydajemy nowy i zaawansowany edytor obrazów, który dodaje więcej klatek przycinania, filtry dla szybkich edycji, precyzyjne opcje dostrajania, w tym nasycenie, kontrast, jasność, temperatura i wiele więcej. Nowy edytor zawiera również możliwość rysowania zdjęć i dodawania emotikonów jako naklejki.",
"cLTitle2": "Inteligentne Albumy",
"cLDesc2": "Teraz możesz automatycznie dodawać zdjęcia wybranych osób do dowolnego albumu. Po prostu przejdź do albumu i wybierz \"automatycznie dodaj osoby\" z menu przepełnienia. Jeśli używane razem z udostępnionym albumem, możesz udostępniać zdjęcia bez żadnych kliknięć.",
"cLTitle3": "Ulepszona Galeria",
"cLDesc3": "Dodaliśmy możliwość grupowania Twojej galerii po tygodniach, miesiącach i latach. Możesz teraz spersonalizować swoją galerię, aby dokładnie wyglądać w ten sposób z nowymi opcjami grupowania, wraz z niestandardowymi siatkami",
"cLTitle4": "Szybsze Przewijanie",
"cLDesc4": "Wraz z kilkoma ulepszeniami w celu poprawy doświadczenia galerii, przeprojektowaliśmy również pasek przewijania, aby pokazywać znaczniki, umożliwiając szybki skok po osi czasu.",
"indexingPausedStatusDescription": "Indeksowanie zostało wstrzymane. Zostanie automatycznie wznowione, gdy urządzenie będzie gotowe. Urządzenie uznaje się za gotowe, gdy poziom baterii, stan jej zdrowia oraz status termiczny znajdują się w bezpiecznym zakresie.",
"thisWeek": "Ten tydzień",
"lastWeek": "Zeszły tydzień",
@@ -1819,11 +1827,5 @@
"type": "int"
}
}
},
"cLTitle1": "Podobne obrazy",
"cLDesc1": "Wprowadzamy nowy system oparty na ML do wykrywania podobnych obrazów, za pomocą którego możesz posprzątać swoją bibliotekę. Dostępne w Ustawienia->Kopia zapasowa->Zwolnij miejsce",
"cLTitle2": "Ulepszenia streamingu wideo",
"cLDesc2": "Możesz teraz ręcznie wyzwolić generowanie strumienia dla filmów bezpośrednio z aplikacji. Dodaliśmy również nowy ekran ustawień streamingu wideo, który pokaże ci, jaki procent twoich filmów zostało przetworzonych do streamingu",
"cLTitle3": "Ulepszenia wydajności",
"cLDesc3": "Liczne ulepszenia pod maską, w tym lepsze wykorzystanie pamięci podręcznej i płynniejsze przewijanie"
}
}
}

View File

@@ -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",

View File

@@ -1776,6 +1776,14 @@
"same": "Igual",
"different": "Diferente",
"sameperson": "Mesma pessoa?",
"cLTitle1": "Editor de Imagens Avançado",
"cLDesc1": "Estamos lançando um novo editor de fotos avançado que adiciona mais quadros de recorte, predefinições de filtro para edições rápidas, ajustes para afinação incluindo saturação, contraste, brilho, temperatura e mais. O novo editor também incluí a habilidade de desenhar em suas fotos e adicionar emojis como figurinhas.",
"cLTitle2": "Álbuns Inteligentes",
"cLDesc2": "Você agora pode adicionar automaticamente fotos de pessoas selecionadas para qualquer álbum. É só ir ao álbum, selecionar \"adicionar pessoa auto.\" no menu avançado. Se usado junto ao álbum compartilhado, você pode compartilhar fotos sem maior esforço.",
"cLTitle3": "Galeria Aprimorada",
"cLDesc3": "Adicionamos a habilidade de agrupar sua galeria por semanas, meses, e anos. Você pode personalizar sua galeria para parecer exatamente a maneira que desejar usando as novas opções de agrupamento, junto às grades personalizadas",
"cLTitle4": "Arrastar Rápido",
"cLDesc4": "Junto ao tanto de melhorias salva-vidas para melhorar a experiência de arraste na galeria, também redesenhamos a barra de deslize para exibir marcadores, permitindo você pular a timeline rapidamente.",
"indexingPausedStatusDescription": "A indexação foi pausada. Ela retomará automaticamente quando o dispositivo estiver pronto. O dispositivo é considerado pronto quando o nível de bateria, saúde da bateria, e estado térmico estejam num alcance saudável.",
"thisWeek": "Esta semana",
"lastWeek": "Semana passada",
@@ -1819,11 +1827,5 @@
"type": "int"
}
}
},
"cLTitle1": "Imagens similares",
"cLDesc1": "Estamos introduzindo um novo sistema baseado em ML para detectar imagens similares, com o qual você pode limpar sua biblioteca. Disponível em Configurações -> Backup -> Liberar espaço",
"cLTitle2": "Melhorias do streaming de vídeo",
"cLDesc2": "Agora você pode acionar manualmente a geração de stream para vídeos diretamente do aplicativo. Também adicionamos uma nova tela de configurações de streaming de vídeo que mostrará qual porcentagem dos seus vídeos foram processados para streaming",
"cLTitle3": "Melhorias de desempenho",
"cLDesc3": "Múltiplas melhorias internas, incluindo melhor uso de cache e uma experiência de rolagem mais suave"
}
}
}

View File

@@ -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",
@@ -1776,6 +1776,14 @@
"same": "Igual",
"different": "Diferente",
"sameperson": "A mesma pessoa?",
"cLTitle1": "Editor de Imagens Avançado",
"cLDesc1": "Estamos a lançar um novo editor avançado que adiciona mais ecrãs de recorte, predefinições de filtro para edições ágeis, ajustes de afinação incluindo saturação, contraste, brilho, temperatura e mais além. O novo editor também será possível desenhar nas suas fotos e adicionar emojis como autocolantes.",
"cLTitle2": "Álbuns Inteligentes",
"cLDesc2": "Agora pode automaticamente adicionar fotos de pessoas selecionadas para qualquer álbum. É só ir até o álbum, e clicar \"auto adicionar pessoa\" no menu expandido. Se usado com o álbum, pode partilhar fotos sem esforço.",
"cLTitle3": "Fototeca Improvisada",
"cLDesc3": "Adicionamos o agrupamento à sua fototeca, com filtro de semanas, meses, e anos. Pode personalizar a sua fototeca para parecer como desejar ao usar as novas definições de agrupamento, junto às grades personalizadas",
"cLTitle4": "Arraste Ágil",
"cLDesc4": "Junto às improvisações salva-vidas para melhorar a experiência de arraste na fototeca, também redesenhamos o slider para mostrar marcadores, permitindo você pular a linha do tempo mais fácil.",
"indexingPausedStatusDescription": "A indexação foi interrompida. Ele será retomado se o dispositivo estiver pronto. O dispositivo é considerado pronto se o nível de bateria, saúde da bateria, e estado térmico esteja num estado saudável.",
"thisWeek": "Esta semana",
"lastWeek": "Semana passada",
@@ -1819,11 +1827,5 @@
"type": "int"
}
}
},
"cLTitle1": "Imagens similares",
"cLDesc1": "Estamos a introduzir um novo sistema baseado em ML para detectar imagens similares, com o qual pode limpar a sua biblioteca. Disponível em Definições -> Cópia de segurança -> Libertar espaço",
"cLTitle2": "Melhorias do streaming de vídeo",
"cLDesc2": "Agora pode accionar manualmente a geração de stream para vídeos directamente da aplicação. Também adicionámos um novo ecrã de definições de streaming de vídeo que mostrará que percentagem dos seus vídeos foram processados para streaming",
"cLTitle3": "Melhorias de desempenho",
"cLDesc3": "Múltiplas melhorias internas, incluindo melhor uso de cache e uma experiência de deslocação mais suave"
}
}

View File

@@ -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": {
@@ -1521,11 +1521,5 @@
"joinAlbum": "Alăturați-vă albumului",
"joinAlbumSubtext": "pentru a vedea și a adăuga fotografii",
"joinAlbumSubtextViewer": "pentru a adăuga la albumele distribuite",
"join": "Alăturare",
"cLTitle1": "Imagini similare",
"cLDesc1": "Introducem un nou sistem bazat pe ML pentru detectarea imaginilor similare, cu care vă puteți curăța biblioteca. Disponibil în Setări->Backup->Eliberați Spațiu",
"cLTitle2": "Îmbunătățiri streaming video",
"cLDesc2": "Acum puteți declanșa manual generarea fluxului pentru videoclipuri direct din aplicație. Am adăugat, de asemenea, un nou ecran de setări pentru streaming video care vă va arăta ce procent din videoclipurile dvs. au fost procesate pentru streaming",
"cLTitle3": "Îmbunătățiri de Performanță",
"cLDesc3": "Multiple îmbunătățiri în fundal, inclusiv o utilizare mai bună a cache-ului și o experiență de defilare mai fluidă"
}
"join": "Alăturare"
}

View File

@@ -477,7 +477,7 @@
}
},
"showMemories": "Показывать воспоминания",
"yearsAgo": "{count, plural, one{{count} год назад} few{{count} года назад} other{{count} лет назад}}",
"yearsAgo": "{count, plural, one{{count} год назад} other{{count} лет назад}}",
"backupSettings": "Настройки резервного копирования",
"backupStatus": "Статус резервного копирования",
"backupStatusDescription": "Элементы, сохранённые в резервной копии, появятся здесь",
@@ -1785,11 +1785,5 @@
"analysis": "Анализ",
"day": "День",
"filter": "Фильтр",
"font": "Шрифт",
"cLTitle1": "Похожие изображения",
"cLDesc1": "Мы внедряем новую систему на основе ML для обнаружения похожих изображений, с помощью которой вы можете очистить свою библиотеку. Доступно в Настройки->Резервная копия->Освободить место",
"cLTitle2": "Улучшения видео стриминга",
"cLDesc2": "Теперь вы можете вручную запустить генерацию потока для видео прямо из приложения. Мы также добавили новый экран настроек видео стриминга, который покажет вам, какой процент ваших видео был обработан для стриминга",
"cLTitle3": "Улучшения производительности",
"cLDesc3": "Множественные улучшения под капотом, включая лучшее использование кэша и более плавную прокрутку"
}
"font": "Шрифт"
}

View File

@@ -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 @@
}
}
}
}
}

View File

@@ -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"
}
}

View File

@@ -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": {
@@ -1776,11 +1776,5 @@
"same": "Aynı",
"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.",
"cLTitle1": "Benzer görüntüler",
"cLDesc1": "Benzer görüntüleri tespit etmek için yeni bir ML tabanlı sistem tanıtıyoruz, bununla kütüphanenizi temizleyebilirsiniz. Ayarlar -> Yedekleme -> Alan boşalt kısmından ulaşabilirsiniz",
"cLTitle2": "Video akış geliştirmeleri",
"cLDesc2": "Artık doğrudan uygulamadan videolar için akış oluşturmayı manuel olarak tetikleyebilirsiniz. Ayrıca videolarınızın yüzde kaçının akış için işlendiğini gösteren yeni bir video akış ayarları ekranı da ekledik",
"cLTitle3": "Performans İyileştirmeleri",
"cLDesc3": "Daha iyi önbellek kullanımı ve daha pürüzsüz kaydırma deneyimi dahil olmak üzere perde arkasında birçok iyileştirme"
}
"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."
}

View File

@@ -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": {
@@ -1509,11 +1509,5 @@
},
"legacyInvite": "{email} запросив вас стати довіреною особою",
"authToManageLegacy": "Авторизуйтесь, щоби керувати довіреними контактами",
"useDifferentPlayerInfo": "Виникли проблеми з відтворенням цього відео? Натисніть і утримуйте тут, щоб спробувати інший плеєр.",
"cLTitle1": "Схожі зображення",
"cLDesc1": "Ми впроваджуємо нову систему на основі ML для виявлення схожих зображень, за допомогою якої ви можете очистити свою бібліотеку. Доступно в Налаштування->Резервна копія->Звільнити місце",
"cLTitle2": "Покращення відео стрімінгу",
"cLDesc2": "Тепер ви можете вручну запустити генерацію потоку для відео прямо з додатку. Ми також додали новий екран налаштувань відео стрімінгу, який покаже вам, який відсоток ваших відео було оброблено для стрімінгу",
"cLTitle3": "Покращення продуктивності",
"cLDesc3": "Численні покращення під капотом, включаючи краще використання кешу та більш плавну прокрутку"
}
"useDifferentPlayerInfo": "Виникли проблеми з відтворенням цього відео? Натисніть і утримуйте тут, щоб спробувати інший плеєр."
}

View File

@@ -1776,6 +1776,14 @@
"same": "Chính xác",
"different": "Khác",
"sameperson": "Cùng một người?",
"cLTitle1": "Trình chỉnh sửa ảnh nâng cao",
"cLDesc1": "Chúng tôi phát hành một trình chỉnh sửa ảnh tân tiến, bổ sung thêm cắt ảnh, bộ lọc có sẵn để chỉnh sửa nhanh, các tùy chọn tinh chỉnh bao gồm độ bão hòa, độ tương phản, độ sáng, độ ấm và nhiều hơn nữa. Trình chỉnh sửa mới cũng bao gồm khả năng vẽ lên ảnh và thêm emoji dưới dạng nhãn dán.",
"cLTitle2": "Album thông minh",
"cLDesc2": "Giờ đây, bạn có thể tự động thêm ảnh của những người đã chọn vào bất kỳ album nào. Chỉ cần mở album và chọn \"Tự động thêm người\" trong menu. Nếu sử dụng cùng với album chia sẻ, bạn có thể chia sẻ ảnh mà không cần tốn công.",
"cLTitle3": "Cải tiến Thư viện ảnh",
"cLDesc3": "Chúng tôi bổ sung tính năng phân nhóm thư viện ảnh theo tuần, tháng và năm. Giờ đây, bạn có thể tùy chỉnh thư viện ảnh theo đúng ý muốn với các tùy chọn mới này, cùng với các lưới tùy chỉnh.",
"cLTitle4": "Cuộn nhanh hơn",
"cLDesc4": "Cùng với một loạt cải tiến ngầm nhằm nâng cao trải nghiệm cuộn thư viện, chúng tôi cũng đã thiết kế lại thanh cuộn để hiển thị các điểm đánh dấu, cho phép bạn nhanh chóng nhảy cóc trên dòng thời gian.",
"indexingPausedStatusDescription": "Lập chỉ mục bị tạm dừng. Nó sẽ tự động tiếp tục khi thiết bị đã sẵn sàng. Thiết bị được coi là sẵn sàng khi mức pin, tình trạng pin và trạng thái nhiệt độ nằm trong phạm vi tốt.",
"thisWeek": "Tuần này",
"lastWeek": "Tuần trước",
@@ -1931,11 +1939,5 @@
"related": "Có liên quan",
"hoorayyyy": "Hoorayyyy!",
"nothingToTidyUpHere": "Ở đây đã ngon lành rồi",
"deletingDash": "Đang xóa - ",
"cLTitle1": "Hình ảnh tương tự",
"cLDesc1": "Chúng tôi đang giới thiệu một hệ thống dựa trên ML mới để phát hiện hình ảnh tương tự, bạn có thể dùng để dọn dẹp thư viện của mình. Có sẵn trong Cài đặt -> Sao lưu -> Giải phóng dung lượng",
"cLTitle2": "Cải thiện streaming video",
"cLDesc2": "Bây giờ bạn có thể kích hoạt tạo luồng cho video trực tiếp từ ứng dụng. Chúng tôi cũng đã thêm màn hình cài đặt phát trực tuyến video mới sẽ cho bạn biết bao nhiêu phần trăm video của bạn đã được xử lý để phát trực tuyến",
"cLTitle3": "Cải Thiện Hiệu Suất",
"cLDesc3": "Nhiều cải thiện bên trong, bao gồm sử dụng bộ nhớ đệm tốt hơn và trải nghiệm cuộn mượt mà hơn"
}
"deletingDash": "Đang xóa - "
}

View File

@@ -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": "已备份的项目将显示在此处",
@@ -1776,6 +1776,14 @@
"same": "相同",
"different": "不同",
"sameperson": "是同一个人?",
"cLTitle1": "高级图像编辑器",
"cLDesc1": "我们正在发布一款全新且高级的图像编辑器,新增更多裁剪框架、快速编辑的滤镜预设,以及包括饱和度、对比度、亮度、色温等在内的精细调整选项。新的编辑器还支持在照片上绘制和添加表情符号作为贴纸。",
"cLTitle2": "智能相册",
"cLDesc2": "您现在可以将所选人物的照片自动添加到任何相册。只需进入相册,从溢出菜单中选择“自动添加人物”。如果与共享相册一起使用,您可以零点击分享照片。",
"cLTitle3": "改进的相册",
"cLDesc3": "我们新增了按周、月、年对图库进行分组的功能。您现在可以通过这些新的分组选项以及自定义网格,定制图库的外观,完全按照您的喜好进行设置",
"cLTitle4": "更快滚动",
"cLDesc4": "除了多项后台改进以提升图库滚动体验外,我们还重新设计了滚动条,添加了标记功能,让您可以快速跳转到时间轴上的不同位置。",
"indexingPausedStatusDescription": "索引已暂停。待设备准备就绪后,索引将自动恢复。当设备的电池电量、电池健康度和温度状态处于健康范围内时,设备即被视为准备就绪。",
"thisWeek": "本周",
"lastWeek": "上周",
@@ -1925,11 +1933,5 @@
"nothingHereTryAnotherFilter": "此处无内容,请尝试其他过滤器!👀",
"related": "相关",
"hoorayyyy": "耶~~!",
"nothingToTidyUpHere": "这里没什么可清理的",
"cLTitle1": "相似图像",
"cLDesc1": "我们正在推出一个基于机器学习的新系统来检测相似图像,您可以用它来清理您的图库。在 设置 -> 备份 -> 释放空间 中可用",
"cLTitle2": "视频流媒体增强",
"cLDesc2": "您现在可以直接从应用程序手动触发视频的流生成。我们还添加了一个新的视频流设置屏幕,它将显示您的视频中有百分之几已被处理用于流媒体播放",
"cLTitle3": "性能改进",
"cLDesc3": "多个底层改进,包括更好的缓存使用和更流畅的滚动体验"
"nothingToTidyUpHere": "这里没什么可清理的"
}

View File

@@ -0,0 +1,3 @@
import "dart:developer";
var devLog = log;

View File

@@ -1,10 +1,10 @@
import 'dart:async';
import "dart:core";
import 'dart:io';
import "package:adaptive_theme/adaptive_theme.dart";
import "package:computer/computer.dart";
import 'package:ente_crypto/ente_crypto.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import "package:flutter/rendering.dart";
@@ -25,19 +25,21 @@ 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';
import 'package:photos/services/machine_learning/semantic_search/semantic_search_service.dart';
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";
@@ -46,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';
@@ -166,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);
@@ -257,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);
@@ -272,12 +273,11 @@ Future<void> _init(bool isBackground, {String via = ''}) async {
}
if (Platform.isIOS) {
// ignore: unawaited_futures
// PushService.instance.init().then((_) {
// FirebaseMessaging.onBackgroundMessage(
// _firebaseMessagingBackgroundHandler,
// );
// });
PushService.instance.init().then((_) {
FirebaseMessaging.onBackgroundMessage(
_firebaseMessagingBackgroundHandler,
);
}).ignore();
}
_logger.info("PushService/HomeWidget done $tlog");
unawaited(SemanticSearchService.instance.init());
@@ -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,
),
);
@@ -402,6 +402,31 @@ Future<bool> _isRunningInForeground() async {
(currentTime - kFGTaskDeathTimeoutInMicroseconds);
}
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
final bool isRunningInFG = await _isRunningInForeground(); // hb
final bool isInForeground = AppLifecycleService.instance.isForeground;
if (await _isRunningInForeground()) {
_logger.info(
"Background push received when app is alive and runningInFS: $isRunningInFG inForeground: $isInForeground",
);
if (PushService.shouldSync(message)) {
await _sync('firebaseBgSyncActiveProcess');
}
} else {
// App is dead
runWithLogs(
() async {
_logger.info("Background push received");
await _init(true, via: 'firebasePush');
if (PushService.shouldSync(message)) {
await _sync('firebaseBgSyncNoActiveProcess');
}
},
prefix: "[fbg]",
).ignore();
}
}
Future<void> _logFGHeartBeatInfo(SharedPreferences prefs) async {
final bool isRunningInFG = await _isRunningInForeground();
await prefs.reload();

View File

@@ -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;
}

View File

@@ -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,
};
}
}

View 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,
];
}
}

View 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,
};
}
}

View File

@@ -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,34 +14,18 @@ class Collection {
final int id;
final User owner;
final String encryptedKey;
final String? keyDecryptionNonce;
/// WARNING: use collectionName instead of name! Name is deprecated but can't be removed because of old accounts.
// 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;
@@ -48,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;
@@ -70,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;
}
@@ -123,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;
}
@@ -192,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) {
@@ -206,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,
];
}
}

View 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,
);
}
}

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