Compare commits
401 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38503e8673 | ||
|
|
c5319f2ba8 | ||
|
|
5d851d8f90 | ||
|
|
dd76317bb0 | ||
|
|
5519981f9c | ||
|
|
1c249560c0 | ||
|
|
130cc9137c | ||
|
|
95f2d282b5 | ||
|
|
e6668606db | ||
|
|
316c767ffa | ||
|
|
c08b0f7aa4 | ||
|
|
b5a4bcf98f | ||
|
|
dc06a2e193 | ||
|
|
c29446e00a | ||
|
|
5aa36921d8 | ||
|
|
3b3af65d74 | ||
|
|
9ad88d3908 | ||
|
|
012e9091f0 | ||
|
|
8ff0f237e7 | ||
|
|
c80e4a65b8 | ||
|
|
c317f2494f | ||
|
|
264b0b151a | ||
|
|
e5cb3e7005 | ||
|
|
adc1939638 | ||
|
|
2cc1c36b7b | ||
|
|
b99450615e | ||
|
|
5de1f0c93b | ||
|
|
ee902a5ccb | ||
|
|
9e56afaa73 | ||
|
|
77f8c3f712 | ||
|
|
f7a0a414db | ||
|
|
bd49b8464b | ||
|
|
0cbfa319ba | ||
|
|
95e05f167c | ||
|
|
84ef4c2d0b | ||
|
|
53c14dff01 | ||
|
|
02ef29fd8f | ||
|
|
1848f1a94b | ||
|
|
276c38236f | ||
|
|
5e3e3b4427 | ||
|
|
b8dab3ea1c | ||
|
|
2dc32f5339 | ||
|
|
9fd6bc4974 | ||
|
|
3740e9e29d | ||
|
|
118dde38a2 | ||
|
|
cd15fe86c6 | ||
|
|
693a40cc24 | ||
|
|
2cfa8497da | ||
|
|
3f6b9d7ae6 | ||
|
|
e71b6dbb63 | ||
|
|
44722c40c2 | ||
|
|
bfd13b99d2 | ||
|
|
e82d878fa8 | ||
|
|
0587813c70 | ||
|
|
b7712a7c51 | ||
|
|
213f3cd122 | ||
|
|
d103274da5 | ||
|
|
28b244ebc5 | ||
|
|
7d24dea7bc | ||
|
|
4a358a7793 | ||
|
|
699249cd26 | ||
|
|
7125ac7419 | ||
|
|
de67b6e9fc | ||
|
|
39868de5d9 | ||
|
|
751550d469 | ||
|
|
6f0034dd9d | ||
|
|
7bd6180ebf | ||
|
|
5a3ae5f97c | ||
|
|
e447058573 | ||
|
|
8a7e8b8237 | ||
|
|
ed7b1be591 | ||
|
|
9a5ef8b634 | ||
|
|
c2668387b5 | ||
|
|
d265cf62c7 | ||
|
|
a67c3b0624 | ||
|
|
306c78fbb0 | ||
|
|
e4f9dd6b33 | ||
|
|
5fec59f0fe | ||
|
|
53050ca25e | ||
|
|
926aa42168 | ||
|
|
f4f8141b99 | ||
|
|
3d044dd3d4 | ||
|
|
8699ad2f01 | ||
|
|
01aad531f1 | ||
|
|
6340d9f646 | ||
|
|
0f944b1796 | ||
|
|
1d8533168f | ||
|
|
2fca1ba534 | ||
|
|
2d386c769b | ||
|
|
1b41d81839 | ||
|
|
487156c7df | ||
|
|
35c54111e7 | ||
|
|
d71340fbdd | ||
|
|
f32ea85ee2 | ||
|
|
6397ab888a | ||
|
|
5a73043b63 | ||
|
|
f258c40e98 | ||
|
|
36b6476049 | ||
|
|
c4c5ea150f | ||
|
|
1956b3788b | ||
|
|
72cbddff6d | ||
|
|
17670d5538 | ||
|
|
8cfd80663e | ||
|
|
f285e2d706 | ||
|
|
f60e074dd5 | ||
|
|
140eae6859 | ||
|
|
23ee022472 | ||
|
|
b1cf3f9fb0 | ||
|
|
dbbb80a817 | ||
|
|
91a10634cc | ||
|
|
0a2c230254 | ||
|
|
6745b110df | ||
|
|
7061161181 | ||
|
|
f0026f0a81 | ||
|
|
acec985bcb | ||
|
|
c8103a9e06 | ||
|
|
02f64ad45f | ||
|
|
d0931d1d0e | ||
|
|
5c78de5355 | ||
|
|
1aa9f61419 | ||
|
|
0f2b51d1a5 | ||
|
|
9fef560d15 | ||
|
|
09c7bfd717 | ||
|
|
2ff059a701 | ||
|
|
70b043d34a | ||
|
|
15a00379b5 | ||
|
|
7246ade2ae | ||
|
|
7cad0a83d2 | ||
|
|
e6703aef65 | ||
|
|
662dfad7ca | ||
|
|
466ab30f8b | ||
|
|
12c1845a5f | ||
|
|
2d0202df36 | ||
|
|
2ec4f5a7e5 | ||
|
|
6723bed1b0 | ||
|
|
84e8ce519e | ||
|
|
1b50528181 | ||
|
|
08dff77ad4 | ||
|
|
713abce89a | ||
|
|
da15593a47 | ||
|
|
fc31cc61d1 | ||
|
|
9c6259b713 | ||
|
|
ed603232a5 | ||
|
|
ccd89d3451 | ||
|
|
647b2ef4a7 | ||
|
|
f54c79462e | ||
|
|
eb81d96ddf | ||
|
|
57363a24ef | ||
|
|
9431995e8c | ||
|
|
64b86376f6 | ||
|
|
a825367c49 | ||
|
|
1ffbb27ac5 | ||
|
|
d4add9f7ef | ||
|
|
541494613f | ||
|
|
2b3427e40b | ||
|
|
a57c9e881d | ||
|
|
d15f1e15ce | ||
|
|
0411f8ad40 | ||
|
|
2981816c90 | ||
|
|
a6de98ef68 | ||
|
|
18156ce8bc | ||
|
|
458c1cf86d | ||
|
|
90c0874608 | ||
|
|
928ffba4d7 | ||
|
|
0701212540 | ||
|
|
347bf4d2e0 | ||
|
|
2729edfded | ||
|
|
4e8d2c5cea | ||
|
|
84e9336672 | ||
|
|
cecdea3f93 | ||
|
|
37674deba0 | ||
|
|
733be57df8 | ||
|
|
74df52baf1 | ||
|
|
b817c4475e | ||
|
|
f95dac31d2 | ||
|
|
151289b24a | ||
|
|
5dac9d4dd6 | ||
|
|
43b9dbdc54 | ||
|
|
9d3caaa5d5 | ||
|
|
7eda2ed24e | ||
|
|
30df5271b4 | ||
|
|
4b1f7612a3 | ||
|
|
bf6521e8d5 | ||
|
|
4bac1bcb1d | ||
|
|
b123635584 | ||
|
|
d815143bb4 | ||
|
|
ff6228497f | ||
|
|
7469578e77 | ||
|
|
76afef6149 | ||
|
|
2b3178495a | ||
|
|
d6f3ff8db3 | ||
|
|
7b0ef2b0c0 | ||
|
|
35f95010ea | ||
|
|
233f0ec1e1 | ||
|
|
64820ff5fa | ||
|
|
86ffd4e1e6 | ||
|
|
436a02d352 | ||
|
|
0f3b8bae48 | ||
|
|
0f270a379f | ||
|
|
609f6b8e18 | ||
|
|
7896b397c2 | ||
|
|
097078bd24 | ||
|
|
e43e3c4230 | ||
|
|
439f1ff0fb | ||
|
|
e5d78cfd99 | ||
|
|
bd76e66abf | ||
|
|
9e6d7908a9 | ||
|
|
281735e172 | ||
|
|
bc93aca110 | ||
|
|
df6e409ca1 | ||
|
|
7489821434 | ||
|
|
52e0f04ec2 | ||
|
|
ad88ce632c | ||
|
|
26222ec836 | ||
|
|
beeaee4fd9 | ||
|
|
dc98c7bcf5 | ||
|
|
82497563c2 | ||
|
|
4a9b4520d2 | ||
|
|
756c8e5b7d | ||
|
|
dffb920cab | ||
|
|
fa7ddbba0c | ||
|
|
13a068969c | ||
|
|
717e8c8b7e | ||
|
|
015adb595c | ||
|
|
91635d2e7d | ||
|
|
cd3499a004 | ||
|
|
92a964cda6 | ||
|
|
a9ba615962 | ||
|
|
f2b0c11622 | ||
|
|
83b89b6bbf | ||
|
|
ee0a858302 | ||
|
|
b34c923a66 | ||
|
|
fd927d038b | ||
|
|
64e9902f57 | ||
|
|
c3af79d113 | ||
|
|
d87e679650 | ||
|
|
7e2242dc69 | ||
|
|
9adc207b02 | ||
|
|
36049f6633 | ||
|
|
9341bc95ee | ||
|
|
252dca1a01 | ||
|
|
70501054d2 | ||
|
|
be012e0a28 | ||
|
|
340a0c097f | ||
|
|
7fc8649455 | ||
|
|
c97a313edb | ||
|
|
480fdc84dc | ||
|
|
c92ef45c9a | ||
|
|
ca62012a6f | ||
|
|
151a0d13a4 | ||
|
|
747b1b84c6 | ||
|
|
e060fb9823 | ||
|
|
cd377149bc | ||
|
|
d9e22a489b | ||
|
|
524db74bf5 | ||
|
|
e1222d51a9 | ||
|
|
1c68f0bb60 | ||
|
|
f6419caf5c | ||
|
|
441bcbd187 | ||
|
|
4ad3927348 | ||
|
|
4ae15e5966 | ||
|
|
d67d1d3df8 | ||
|
|
07e1d33ca8 | ||
|
|
50e15fa56c | ||
|
|
3f262c5ba2 | ||
|
|
7f34870e3a | ||
|
|
8ba5013926 | ||
|
|
e9a24efecb | ||
|
|
eaf74e4059 | ||
|
|
e9e1c3ca27 | ||
|
|
eb34533aed | ||
|
|
cd042e741e | ||
|
|
6e944b0b55 | ||
|
|
18b6b499dd | ||
|
|
9205ef8219 | ||
|
|
1ecdbdb88e | ||
|
|
e2bb4d723e | ||
|
|
cfe32c47f0 | ||
|
|
a7f6b6589d | ||
|
|
715e305e09 | ||
|
|
f7330be52c | ||
|
|
4cce54a0c6 | ||
|
|
1850e9a2a6 | ||
|
|
21e2b589cc | ||
|
|
b7f8deb452 | ||
|
|
b6ffb3ca22 | ||
|
|
9351a52800 | ||
|
|
c7510024c0 | ||
|
|
63d3b1c94b | ||
|
|
cec27b40a4 | ||
|
|
2f8d0d1957 | ||
|
|
7f0a36f110 | ||
|
|
355367a601 | ||
|
|
c66da422cd | ||
|
|
6f90fad4a2 | ||
|
|
bcd6f55376 | ||
|
|
b2766a0d4f | ||
|
|
ec1b95b0cd | ||
|
|
4369317a4d | ||
|
|
b18298dc62 | ||
|
|
b3e467a1a4 | ||
|
|
f8cd3d9fb4 | ||
|
|
6d4756ca4b | ||
|
|
676bbb4d88 | ||
|
|
da8edfd34e | ||
|
|
bf453cfaac | ||
|
|
35f41f044e | ||
|
|
acff269695 | ||
|
|
2b4f96dbb7 | ||
|
|
d5796e2abb | ||
|
|
afe9690891 | ||
|
|
fc619bbd03 | ||
|
|
a198331ffd | ||
|
|
c58fe5358d | ||
|
|
defc5164b9 | ||
|
|
97d4fb0693 | ||
|
|
e708564cb9 | ||
|
|
92068e026b | ||
|
|
8a079ab4f4 | ||
|
|
b41c57cb8d | ||
|
|
87167e49fc | ||
|
|
089d1dcd10 | ||
|
|
db02e66124 | ||
|
|
964066bf31 | ||
|
|
643220e595 | ||
|
|
e871498161 | ||
|
|
31a88b74df | ||
|
|
1801258fea | ||
|
|
27acc2125b | ||
|
|
60b7a91756 | ||
|
|
b38a01820d | ||
|
|
347d5a7a72 | ||
|
|
82979ac729 | ||
|
|
5768eeb1fe | ||
|
|
7a9ff9877a | ||
|
|
3565540a61 | ||
|
|
06d78e5d6a | ||
|
|
6c11b76c11 | ||
|
|
749cfde7d8 | ||
|
|
6bcaa8ae26 | ||
|
|
99c6318b0e | ||
|
|
abf789a4aa | ||
|
|
f5d3712cbb | ||
|
|
45dd540abc | ||
|
|
865a736bdd | ||
|
|
75bd1bfef6 | ||
|
|
4cf67fe171 | ||
|
|
65895328dc | ||
|
|
a1c20b9c8a | ||
|
|
de9def5370 | ||
|
|
9cc723a280 | ||
|
|
16beae2a82 | ||
|
|
2e25c38324 | ||
|
|
7e691f84e4 | ||
|
|
d99593fc85 | ||
|
|
d4877ea446 | ||
|
|
5f4c748886 | ||
|
|
eaab58c62a | ||
|
|
c1c020402e | ||
|
|
028f4e61d2 | ||
|
|
abc6f56247 | ||
|
|
822eb59761 | ||
|
|
fade7859ab | ||
|
|
f85047fb28 | ||
|
|
3f81c9beae | ||
|
|
b7fa8d7c89 | ||
|
|
5f75c5fc3f | ||
|
|
98eaee3b9e | ||
|
|
59bd039bed | ||
|
|
3e90126a55 | ||
|
|
dabcc0aeb5 | ||
|
|
c4b99af0e2 | ||
|
|
679c12bb90 | ||
|
|
3ef8ece8c0 | ||
|
|
24f8cf188a | ||
|
|
233838da3e | ||
|
|
06c4866c75 | ||
|
|
fa71acf91a | ||
|
|
81d40826b3 | ||
|
|
2a14b5e5a3 | ||
|
|
63bbca09f3 | ||
|
|
cbff68bc42 | ||
|
|
070ab80be9 | ||
|
|
0efbf407d3 | ||
|
|
ab22b28695 | ||
|
|
d3466d7efe | ||
|
|
5945f2aaad | ||
|
|
4c79b9cb92 | ||
|
|
4676c363d2 | ||
|
|
f75807d8f0 | ||
|
|
578541308a | ||
|
|
30cded4d3d | ||
|
|
be2aee6baa | ||
|
|
fae23df6eb | ||
|
|
2a1f2aded1 | ||
|
|
6e85d24286 | ||
|
|
0bcc676e44 | ||
|
|
b5a9bab5c6 | ||
|
|
5849d14cd9 | ||
|
|
77cde87927 | ||
|
|
670d6e8470 |
3
.gitmodules
vendored
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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 Photos’s 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.
|
||||
|
||||
|
Before Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 584 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 522 KiB After Width: | Height: | Size: 690 KiB |
|
Before Width: | Height: | Size: 662 KiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 521 KiB After Width: | Height: | Size: 853 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 76 KiB |
@@ -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.
|
||||
|
||||
@@ -1 +1 @@
|
||||
Ente Photos - Encrypted photo storage
|
||||
Ente Photos - Open source, end-to-end encrypted alternative to Google Photos
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 Photos’s 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.
|
||||
|
||||
@@ -1 +1 @@
|
||||
Backup, Organise, Share - Private photo storage with end-to-end encryption
|
||||
ente is an end-to-end encrypted photo storage app
|
||||
@@ -1 +1 @@
|
||||
Ente Photos - Encrypted photo storage
|
||||
ente - encrypted photo storage
|
||||
|
Before Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 584 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 522 KiB After Width: | Height: | Size: 690 KiB |
|
Before Width: | Height: | Size: 662 KiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 521 KiB After Width: | Height: | Size: 853 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 76 KiB |
@@ -29,6 +29,10 @@ class LRUMap<K, V> {
|
||||
}
|
||||
}
|
||||
|
||||
bool containsKey(K key) {
|
||||
return _map.containsKey(key);
|
||||
}
|
||||
|
||||
void remove(K key) {
|
||||
_map.remove(key);
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:photos/core/cache/lru_map.dart';
|
||||
import 'package:photos/core/constants.dart';
|
||||
import 'package:photos/models/file/file.dart';
|
||||
|
||||
class ThumbnailInMemoryLruCache {
|
||||
static final LRUMap<String, Uint8List?> _map = LRUMap(1000);
|
||||
|
||||
static Uint8List? get(EnteFile enteFile, [int? size]) {
|
||||
return _map.get(
|
||||
enteFile.cacheKey() +
|
||||
"_" +
|
||||
(size != null ? size.toString() : thumbnailLargeSize.toString()),
|
||||
);
|
||||
}
|
||||
|
||||
static void put(
|
||||
EnteFile enteFile,
|
||||
Uint8List? imageData, [
|
||||
int? size,
|
||||
]) {
|
||||
_map.put(
|
||||
enteFile.cacheKey() +
|
||||
"_" +
|
||||
(size != null ? size.toString() : thumbnailLargeSize.toString()),
|
||||
imageData,
|
||||
);
|
||||
}
|
||||
|
||||
static void clearCache(EnteFile enteFile) {
|
||||
_map.remove(
|
||||
enteFile.cacheKey() + "_" + thumbnailLargeSize.toString(),
|
||||
);
|
||||
_map.remove(
|
||||
enteFile.cacheKey() + "_" + thumbnailSmallSize.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,9 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:photos/core/constants.dart';
|
||||
import 'package:photos/core/error-reporting/super_logging.dart';
|
||||
import 'package:photos/core/event_bus.dart';
|
||||
import 'package:photos/db/collections_db.dart';
|
||||
import 'package:photos/db/files_db.dart';
|
||||
import "package:photos/db/memories_db.dart";
|
||||
import "package:photos/db/ml/db.dart";
|
||||
import 'package:photos/db/trash_db.dart';
|
||||
import 'package:photos/db/upload_locks_db.dart';
|
||||
import "package:photos/events/endpoint_updated_event.dart";
|
||||
import 'package:photos/events/signed_in_event.dart';
|
||||
@@ -23,6 +21,8 @@ import 'package:photos/events/user_logged_out_event.dart';
|
||||
import 'package:photos/models/api/user/key_attributes.dart';
|
||||
import 'package:photos/models/api/user/key_gen_result.dart';
|
||||
import 'package:photos/models/api/user/private_key_attributes.dart';
|
||||
import 'package:photos/module/upload/service/file_uploader.dart';
|
||||
import "package:photos/service_locator.dart";
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import 'package:photos/services/favorites_service.dart';
|
||||
import "package:photos/services/home_widget_service.dart";
|
||||
@@ -31,7 +31,6 @@ import "package:photos/services/machine_learning/face_ml/person/person_service.d
|
||||
import "package:photos/services/machine_learning/similar_images_service.dart";
|
||||
import 'package:photos/services/search_service.dart';
|
||||
import 'package:photos/services/sync/sync_service.dart';
|
||||
import 'package:photos/utils/file_uploader.dart';
|
||||
import "package:photos/utils/lock_screen_settings.dart";
|
||||
import 'package:photos/utils/validator_util.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -194,14 +193,13 @@ class Configuration {
|
||||
_cachedToken = null;
|
||||
_secretKey = null;
|
||||
await FilesDB.instance.clearTable();
|
||||
await CollectionsDB.instance.clearTable();
|
||||
// await CollectionsDB.instance.clearTable();
|
||||
await MemoriesDB.instance.clearTable();
|
||||
await MLDataDB.instance.clearTable();
|
||||
await remoteDB.clearAllTables();
|
||||
await SimilarImagesService.instance.clearCache();
|
||||
|
||||
await UploadLocksDB.instance.clearTable();
|
||||
await IgnoredFilesService.instance.reset();
|
||||
await TrashDB.instance.clearTable();
|
||||
unawaited(HomeWidgetService.instance.clearWidget(autoLogout));
|
||||
if (!autoLogout) {
|
||||
// Following services won't be initialized if it's the case of autoLogout
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import "package:flutter/foundation.dart";
|
||||
|
||||
const int thumbnailSmallSize = 256;
|
||||
const int thumbnailQuality = 50;
|
||||
const int thumbnailLargeSize = 512;
|
||||
const int compressedThumbnailResolution = 1080;
|
||||
const int thumbnailDataLimit = 100 * 1024;
|
||||
// thumbnailSmallSize Thumbnail sizes in pixels 256px
|
||||
const int thumbnailSmall256 = 256;
|
||||
// thumbnailMediumSize Thumbnail sizes in pixels 512px
|
||||
const int thumbnailLarge512 = 512; // 512px
|
||||
const int compressThumb1080 = 1080;
|
||||
const int thumbnailDataMaxSize = 100 * 1024;
|
||||
const String sentryDSN =
|
||||
"https://2235e5c99219488ea93da34b9ac1cb68@sentry.ente.io/4";
|
||||
const String sentryDebugDSN =
|
||||
@@ -109,4 +111,4 @@ final tempDirCleanUpInterval = kDebugMode
|
||||
const kFilterChipHeight = 32.0;
|
||||
const kMaxAppbarFilters = 14;
|
||||
|
||||
const kLivePhotoHashSeparator = ':';
|
||||
const kHashSeprator = ':';
|
||||
|
||||
@@ -1,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';
|
||||
}
|
||||
}
|
||||
@@ -1,322 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import "package:photos/models/api/collection/public_url.dart";
|
||||
import "package:photos/models/api/collection/user.dart";
|
||||
import 'package:photos/models/collection/collection.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:sqflite_migration/sqflite_migration.dart';
|
||||
|
||||
class CollectionsDB {
|
||||
static const _databaseName = "ente.collections.db";
|
||||
static const table = 'collections';
|
||||
static const tempTable = 'temp_collections';
|
||||
static const _sqlBoolTrue = 1;
|
||||
static const _sqlBoolFalse = 0;
|
||||
|
||||
static const columnID = 'collection_id';
|
||||
static const columnOwner = 'owner';
|
||||
static const columnEncryptedKey = 'encrypted_key';
|
||||
static const columnKeyDecryptionNonce = 'key_decryption_nonce';
|
||||
static const columnName = 'name';
|
||||
static const columnEncryptedName = 'encrypted_name';
|
||||
static const columnNameDecryptionNonce = 'name_decryption_nonce';
|
||||
static const columnType = 'type';
|
||||
static const columnEncryptedPath = 'encrypted_path';
|
||||
static const columnPathDecryptionNonce = 'path_decryption_nonce';
|
||||
static const columnVersion = 'version';
|
||||
static const columnSharees = 'sharees';
|
||||
static const columnPublicURLs = 'public_urls';
|
||||
// MMD -> Magic Metadata
|
||||
static const columnMMdEncodedJson = 'mmd_encoded_json';
|
||||
static const columnMMdVersion = 'mmd_ver';
|
||||
|
||||
static const columnPubMMdEncodedJson = 'pub_mmd_encoded_json';
|
||||
static const columnPubMMdVersion = 'pub_mmd_ver';
|
||||
|
||||
static const columnSharedMMdJson = 'shared_mmd_json';
|
||||
static const columnSharedMMdVersion = 'shared_mmd_ver';
|
||||
|
||||
static const columnUpdationTime = 'updation_time';
|
||||
static const columnIsDeleted = 'is_deleted';
|
||||
|
||||
static final intitialScript = [...createTable(table)];
|
||||
static final migrationScripts = [
|
||||
...alterNameToAllowNULL(),
|
||||
...addEncryptedName(),
|
||||
...addVersion(),
|
||||
...addIsDeleted(),
|
||||
...addPublicURLs(),
|
||||
...addPrivateMetadata(),
|
||||
...addPublicMetadata(),
|
||||
...addShareeMetadata(),
|
||||
];
|
||||
|
||||
final dbConfig = MigrationConfig(
|
||||
initializationScript: intitialScript,
|
||||
migrationScripts: migrationScripts,
|
||||
);
|
||||
|
||||
CollectionsDB._privateConstructor();
|
||||
|
||||
static final CollectionsDB instance = CollectionsDB._privateConstructor();
|
||||
|
||||
static Future<Database>? _dbFuture;
|
||||
|
||||
Future<Database> get database async {
|
||||
_dbFuture ??= _initDatabase();
|
||||
return _dbFuture!;
|
||||
}
|
||||
|
||||
Future<Database> _initDatabase() async {
|
||||
final Directory documentsDirectory =
|
||||
await getApplicationDocumentsDirectory();
|
||||
final String path = join(documentsDirectory.path, _databaseName);
|
||||
return await openDatabaseWithMigration(path, dbConfig);
|
||||
}
|
||||
|
||||
Future<void> clearTable() async {
|
||||
final db = await instance.database;
|
||||
await db.delete(table);
|
||||
}
|
||||
|
||||
static List<String> createTable(String tableName) {
|
||||
return [
|
||||
'''
|
||||
CREATE TABLE $tableName (
|
||||
$columnID INTEGER PRIMARY KEY NOT NULL,
|
||||
$columnOwner TEXT NOT NULL,
|
||||
$columnEncryptedKey TEXT NOT NULL,
|
||||
$columnKeyDecryptionNonce TEXT,
|
||||
$columnName TEXT,
|
||||
$columnType TEXT NOT NULL,
|
||||
$columnEncryptedPath TEXT,
|
||||
$columnPathDecryptionNonce TEXT,
|
||||
$columnSharees TEXT,
|
||||
$columnUpdationTime TEXT NOT NULL
|
||||
);
|
||||
'''
|
||||
];
|
||||
}
|
||||
|
||||
static List<String> alterNameToAllowNULL() {
|
||||
return [
|
||||
...createTable(tempTable),
|
||||
'''
|
||||
INSERT INTO $tempTable
|
||||
SELECT *
|
||||
FROM $table;
|
||||
|
||||
DROP TABLE $table;
|
||||
|
||||
ALTER TABLE $tempTable
|
||||
RENAME TO $table;
|
||||
'''
|
||||
];
|
||||
}
|
||||
|
||||
static List<String> addEncryptedName() {
|
||||
return [
|
||||
'''
|
||||
ALTER TABLE $table
|
||||
ADD COLUMN $columnEncryptedName TEXT;
|
||||
''',
|
||||
'''ALTER TABLE $table
|
||||
ADD COLUMN $columnNameDecryptionNonce TEXT;
|
||||
'''
|
||||
];
|
||||
}
|
||||
|
||||
static List<String> addVersion() {
|
||||
return [
|
||||
'''
|
||||
ALTER TABLE $table
|
||||
ADD COLUMN $columnVersion INTEGER DEFAULT 0;
|
||||
'''
|
||||
];
|
||||
}
|
||||
|
||||
static List<String> addIsDeleted() {
|
||||
return [
|
||||
'''
|
||||
ALTER TABLE $table
|
||||
ADD COLUMN $columnIsDeleted INTEGER DEFAULT $_sqlBoolFalse;
|
||||
'''
|
||||
];
|
||||
}
|
||||
|
||||
static List<String> addPublicURLs() {
|
||||
return [
|
||||
'''
|
||||
ALTER TABLE $table
|
||||
ADD COLUMN $columnPublicURLs TEXT;
|
||||
'''
|
||||
];
|
||||
}
|
||||
|
||||
static List<String> addPrivateMetadata() {
|
||||
return [
|
||||
'''
|
||||
ALTER TABLE $table ADD COLUMN $columnMMdEncodedJson TEXT DEFAULT '{}';
|
||||
''',
|
||||
'''
|
||||
ALTER TABLE $table ADD COLUMN $columnMMdVersion INTEGER DEFAULT 0;
|
||||
'''
|
||||
];
|
||||
}
|
||||
|
||||
static List<String> addPublicMetadata() {
|
||||
return [
|
||||
'''
|
||||
ALTER TABLE $table ADD COLUMN $columnPubMMdEncodedJson TEXT DEFAULT '
|
||||
{}';
|
||||
''',
|
||||
'''
|
||||
ALTER TABLE $table ADD COLUMN $columnPubMMdVersion INTEGER DEFAULT 0;
|
||||
'''
|
||||
];
|
||||
}
|
||||
|
||||
static List<String> addShareeMetadata() {
|
||||
return [
|
||||
'''
|
||||
ALTER TABLE $table ADD COLUMN $columnSharedMMdJson TEXT DEFAULT '
|
||||
{}';
|
||||
''',
|
||||
'''
|
||||
ALTER TABLE $table ADD COLUMN $columnSharedMMdVersion INTEGER DEFAULT 0;
|
||||
'''
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> insert(List<Collection> collections) async {
|
||||
final db = await instance.database;
|
||||
var batch = db.batch();
|
||||
int batchCounter = 0;
|
||||
for (final collection in collections) {
|
||||
if (batchCounter == 400) {
|
||||
await batch.commit(noResult: true);
|
||||
batch = db.batch();
|
||||
batchCounter = 0;
|
||||
}
|
||||
batch.insert(
|
||||
table,
|
||||
_getRowForCollection(collection),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
batchCounter++;
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
Future<List<Collection>> getAllCollections() async {
|
||||
final db = await instance.database;
|
||||
final rows = await db.query(table);
|
||||
final collections = <Collection>[];
|
||||
for (final row in rows) {
|
||||
collections.add(_convertToCollection(row));
|
||||
}
|
||||
return collections;
|
||||
}
|
||||
|
||||
// getActiveCollectionIDsAndUpdationTime returns map of collectionID to
|
||||
// updationTime for non-deleted collections
|
||||
Future<Map<int, int>> getActiveIDsAndRemoteUpdateTime() async {
|
||||
final db = await instance.database;
|
||||
final rows = await db.query(
|
||||
table,
|
||||
where: '($columnIsDeleted = ? OR $columnIsDeleted IS NULL)',
|
||||
whereArgs: [_sqlBoolFalse],
|
||||
columns: [columnID, columnUpdationTime],
|
||||
);
|
||||
final collectionIDsAndUpdationTime = <int, int>{};
|
||||
for (final row in rows) {
|
||||
collectionIDsAndUpdationTime[row[columnID] as int] =
|
||||
int.parse(row[columnUpdationTime] as String);
|
||||
}
|
||||
return collectionIDsAndUpdationTime;
|
||||
}
|
||||
|
||||
Future<int> deleteCollection(int collectionID) async {
|
||||
final db = await instance.database;
|
||||
return db.delete(
|
||||
table,
|
||||
where: '$columnID = ?',
|
||||
whereArgs: [collectionID],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _getRowForCollection(Collection collection) {
|
||||
final row = <String, dynamic>{};
|
||||
row[columnID] = collection.id;
|
||||
row[columnOwner] = collection.owner.toJson();
|
||||
row[columnEncryptedKey] = collection.encryptedKey;
|
||||
row[columnKeyDecryptionNonce] = collection.keyDecryptionNonce;
|
||||
// ignore: deprecated_member_use_from_same_package
|
||||
row[columnName] = collection.name;
|
||||
row[columnEncryptedName] = collection.encryptedName;
|
||||
row[columnNameDecryptionNonce] = collection.nameDecryptionNonce;
|
||||
row[columnType] = typeToString(collection.type);
|
||||
row[columnEncryptedPath] = collection.attributes.encryptedPath;
|
||||
row[columnPathDecryptionNonce] = collection.attributes.pathDecryptionNonce;
|
||||
row[columnVersion] = collection.attributes.version;
|
||||
row[columnSharees] =
|
||||
json.encode(collection.sharees.map((x) => x.toMap()).toList());
|
||||
row[columnPublicURLs] =
|
||||
json.encode(collection.publicURLs.map((x) => x.toMap()).toList());
|
||||
row[columnUpdationTime] = collection.updationTime;
|
||||
if (collection.isDeleted) {
|
||||
row[columnIsDeleted] = _sqlBoolTrue;
|
||||
} else {
|
||||
row[columnIsDeleted] = _sqlBoolFalse;
|
||||
}
|
||||
row[columnMMdVersion] = collection.mMdVersion;
|
||||
row[columnMMdEncodedJson] = collection.mMdEncodedJson ?? '{}';
|
||||
row[columnPubMMdVersion] = collection.mMbPubVersion;
|
||||
row[columnPubMMdEncodedJson] = collection.mMdPubEncodedJson ?? '{}';
|
||||
|
||||
row[columnSharedMMdVersion] = collection.sharedMmdVersion;
|
||||
row[columnSharedMMdJson] = collection.sharedMmdJson ?? '{}';
|
||||
return row;
|
||||
}
|
||||
|
||||
Collection _convertToCollection(Map<String, dynamic> row) {
|
||||
final Collection result = Collection(
|
||||
row[columnID],
|
||||
User.fromJson(row[columnOwner]),
|
||||
row[columnEncryptedKey],
|
||||
row[columnKeyDecryptionNonce],
|
||||
row[columnName],
|
||||
row[columnEncryptedName],
|
||||
row[columnNameDecryptionNonce],
|
||||
typeFromString(row[columnType]),
|
||||
CollectionAttributes(
|
||||
encryptedPath: row[columnEncryptedPath],
|
||||
pathDecryptionNonce: row[columnPathDecryptionNonce],
|
||||
version: row[columnVersion],
|
||||
),
|
||||
List<User>.from(
|
||||
(json.decode(row[columnSharees]) as List).map((x) => User.fromMap(x)),
|
||||
),
|
||||
row[columnPublicURLs] == null
|
||||
? []
|
||||
: List<PublicURL>.from(
|
||||
(json.decode(row[columnPublicURLs]) as List)
|
||||
.map((x) => PublicURL.fromMap(x)),
|
||||
),
|
||||
int.parse(row[columnUpdationTime]),
|
||||
// default to False is columnIsDeleted is not set
|
||||
isDeleted: (row[columnIsDeleted] ?? _sqlBoolFalse) == _sqlBoolTrue,
|
||||
);
|
||||
result.mMdVersion = row[columnMMdVersion] ?? 0;
|
||||
result.mMdEncodedJson = row[columnMMdEncodedJson] ?? '{}';
|
||||
result.mMbPubVersion = row[columnPubMMdVersion] ?? 0;
|
||||
result.mMdPubEncodedJson = row[columnPubMMdEncodedJson] ?? '{}';
|
||||
|
||||
result.sharedMmdVersion = row[columnSharedMMdVersion] ?? 0;
|
||||
result.sharedMmdJson = row[columnSharedMMdJson] ?? '{}';
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import "package:flutter/foundation.dart";
|
||||
import "package:sqlite_async/sqlite_async.dart";
|
||||
|
||||
mixin SqlDbBase {
|
||||
static const _params = {};
|
||||
static final _params = {};
|
||||
|
||||
static String getParams(int count) {
|
||||
String getParams(int count) {
|
||||
if (!_params.containsKey(count)) {
|
||||
final params = List.generate(count, (_) => "?").join(", ");
|
||||
_params[count] = params;
|
||||
@@ -14,9 +14,13 @@ mixin SqlDbBase {
|
||||
|
||||
Future<void> migrate(
|
||||
SqliteDatabase database,
|
||||
List<String> migrationScripts,
|
||||
) async {
|
||||
List<String> migrationScripts, {
|
||||
bool onForeignKey = false,
|
||||
}) async {
|
||||
final result = await database.execute('PRAGMA user_version');
|
||||
if (onForeignKey) {
|
||||
await database.execute("PRAGMA foreign_keys = ON");
|
||||
}
|
||||
final currentVersion = result[0]['user_version'] as int;
|
||||
final toVersion = migrationScripts.length;
|
||||
|
||||
|
||||
@@ -1,492 +0,0 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:photos/db/files_db.dart';
|
||||
import 'package:photos/models/backup_status.dart';
|
||||
import 'package:photos/models/device_collection.dart';
|
||||
import 'package:photos/models/file/file.dart';
|
||||
import 'package:photos/models/file_load_result.dart';
|
||||
import 'package:photos/models/upload_strategy.dart';
|
||||
import "package:photos/services/sync/import/model.dart";
|
||||
import 'package:sqflite/sqlite_api.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
extension DeviceFiles on FilesDB {
|
||||
static final Logger _logger = Logger("DeviceFilesDB");
|
||||
static const _sqlBoolTrue = 1;
|
||||
static const _sqlBoolFalse = 0;
|
||||
|
||||
Future<void> insertPathIDToLocalIDMapping(
|
||||
Map<String, Set<String>> mappingToAdd, {
|
||||
ConflictAlgorithm conflictAlgorithm = ConflictAlgorithm.ignore,
|
||||
}) async {
|
||||
debugPrint("Inserting missing PathIDToLocalIDMapping");
|
||||
final parameterSets = <List<Object?>>[];
|
||||
int batchCounter = 0;
|
||||
for (MapEntry e in mappingToAdd.entries) {
|
||||
final String pathID = e.key;
|
||||
for (String localID in e.value) {
|
||||
parameterSets.add([localID, pathID]);
|
||||
batchCounter++;
|
||||
|
||||
if (batchCounter == 400) {
|
||||
await _insertBatch(parameterSets, conflictAlgorithm);
|
||||
parameterSets.clear();
|
||||
batchCounter = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
await _insertBatch(parameterSets, conflictAlgorithm);
|
||||
parameterSets.clear();
|
||||
batchCounter = 0;
|
||||
}
|
||||
|
||||
Future<void> deletePathIDToLocalIDMapping(
|
||||
Map<String, Set<String>> mappingsToRemove,
|
||||
) async {
|
||||
debugPrint("removing PathIDToLocalIDMapping");
|
||||
final parameterSets = <List<Object?>>[];
|
||||
int batchCounter = 0;
|
||||
for (MapEntry e in mappingsToRemove.entries) {
|
||||
final String pathID = e.key;
|
||||
|
||||
for (String localID in e.value) {
|
||||
parameterSets.add([localID, pathID]);
|
||||
batchCounter++;
|
||||
|
||||
if (batchCounter == 400) {
|
||||
await _deleteBatch(parameterSets);
|
||||
parameterSets.clear();
|
||||
batchCounter = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
await _deleteBatch(parameterSets);
|
||||
parameterSets.clear();
|
||||
batchCounter = 0;
|
||||
}
|
||||
|
||||
Future<Map<String, int>> getDevicePathIDToImportedFileCount() async {
|
||||
try {
|
||||
final db = await sqliteAsyncDB;
|
||||
final rows = await db.getAll(
|
||||
'''
|
||||
SELECT count(*) as count, path_id
|
||||
FROM device_files
|
||||
GROUP BY path_id
|
||||
''',
|
||||
);
|
||||
final result = <String, int>{};
|
||||
for (final row in rows) {
|
||||
result[row['path_id'] as String] = row["count"] as int;
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
_logger.severe("failed to getDevicePathIDToImportedFileCount", e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, Set<String>>> getDevicePathIDToLocalIDMap() async {
|
||||
try {
|
||||
final db = await sqliteAsyncDB;
|
||||
final rows = await db.getAll(
|
||||
''' SELECT id, path_id FROM device_files; ''',
|
||||
);
|
||||
final result = <String, Set<String>>{};
|
||||
for (final row in rows) {
|
||||
final String pathID = row['path_id'] as String;
|
||||
if (!result.containsKey(pathID)) {
|
||||
result[pathID] = <String>{};
|
||||
}
|
||||
result[pathID]!.add(row['id'] as String);
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
_logger.severe("failed to getDevicePathIDToLocalIDMap", e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Set<String>> getDevicePathIDs() async {
|
||||
final db = await sqliteAsyncDB;
|
||||
final rows = await db.getAll(
|
||||
'''
|
||||
SELECT id FROM device_collections
|
||||
''',
|
||||
);
|
||||
final Set<String> result = <String>{};
|
||||
for (final row in rows) {
|
||||
result.add(row['id'] as String);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> insertLocalAssets(
|
||||
List<LocalPathAsset> localPathAssets, {
|
||||
bool shouldAutoBackup = false,
|
||||
}) async {
|
||||
final db = await sqliteAsyncDB;
|
||||
final Map<String, Set<String>> pathIDToLocalIDsMap = {};
|
||||
try {
|
||||
final Set<String> existingPathIds = await getDevicePathIDs();
|
||||
final parameterSetsForUpdate = <List<Object?>>[];
|
||||
final parameterSetsForInsert = <List<Object?>>[];
|
||||
for (LocalPathAsset localPathAsset in localPathAssets) {
|
||||
if (localPathAsset.localIDs.isNotEmpty) {
|
||||
pathIDToLocalIDsMap[localPathAsset.pathID] = localPathAsset.localIDs;
|
||||
}
|
||||
if (existingPathIds.contains(localPathAsset.pathID)) {
|
||||
parameterSetsForUpdate
|
||||
.add([localPathAsset.pathName, localPathAsset.pathID]);
|
||||
} else if (localPathAsset.localIDs.isNotEmpty) {
|
||||
parameterSetsForInsert.add([
|
||||
localPathAsset.pathID,
|
||||
localPathAsset.pathName,
|
||||
shouldAutoBackup ? _sqlBoolTrue : _sqlBoolFalse,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
await db.executeBatch(
|
||||
'''
|
||||
INSERT OR IGNORE INTO device_collections (id, name, should_backup) VALUES (?, ?, ?);
|
||||
''',
|
||||
parameterSetsForInsert,
|
||||
);
|
||||
|
||||
await db.executeBatch(
|
||||
'''
|
||||
UPDATE device_collections SET name = ? WHERE id = ?;
|
||||
''',
|
||||
parameterSetsForUpdate,
|
||||
);
|
||||
|
||||
// add the mappings for localIDs
|
||||
if (pathIDToLocalIDsMap.isNotEmpty) {
|
||||
await insertPathIDToLocalIDMapping(pathIDToLocalIDsMap);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.severe("failed to save path names", e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> updateDeviceCoverWithCount(
|
||||
List<Tuple2<AssetPathEntity, String>> devicePathInfo, {
|
||||
bool shouldBackup = false,
|
||||
}) async {
|
||||
bool hasUpdated = false;
|
||||
try {
|
||||
final db = await sqliteAsyncDB;
|
||||
final Set<String> existingPathIds = await getDevicePathIDs();
|
||||
for (Tuple2<AssetPathEntity, String> tup in devicePathInfo) {
|
||||
final AssetPathEntity pathEntity = tup.item1;
|
||||
final assetCount = await pathEntity.assetCountAsync;
|
||||
final String localID = tup.item2;
|
||||
final bool shouldUpdate = existingPathIds.contains(pathEntity.id);
|
||||
if (shouldUpdate) {
|
||||
final rowUpdated = await db.writeTransaction((tx) async {
|
||||
await tx.execute(
|
||||
"UPDATE device_collections SET name = ?, cover_id = ?, count"
|
||||
" = ? where id = ? AND (name != ? OR cover_id != ? OR count != ?)",
|
||||
[
|
||||
pathEntity.name,
|
||||
localID,
|
||||
assetCount,
|
||||
pathEntity.id,
|
||||
pathEntity.name,
|
||||
localID,
|
||||
assetCount,
|
||||
],
|
||||
);
|
||||
final result = await tx.get("SELECT changes();");
|
||||
return result["changes()"] as int;
|
||||
});
|
||||
|
||||
if (rowUpdated > 0) {
|
||||
_logger.info("Updated $rowUpdated rows for ${pathEntity.name}");
|
||||
hasUpdated = true;
|
||||
}
|
||||
} else {
|
||||
hasUpdated = true;
|
||||
await db.execute(
|
||||
'''
|
||||
INSERT INTO device_collections (id, name, count, cover_id, should_backup)
|
||||
VALUES (?, ?, ?, ?, ?);
|
||||
''',
|
||||
[
|
||||
pathEntity.id,
|
||||
pathEntity.name,
|
||||
assetCount,
|
||||
localID,
|
||||
shouldBackup ? _sqlBoolTrue : _sqlBoolFalse,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
// delete existing pathIDs which are missing on device
|
||||
existingPathIds.removeAll(devicePathInfo.map((e) => e.item1.id).toSet());
|
||||
if (existingPathIds.isNotEmpty) {
|
||||
hasUpdated = true;
|
||||
_logger.info(
|
||||
'Deleting non-backed up pathIds from local '
|
||||
'$existingPathIds',
|
||||
);
|
||||
for (String pathID in existingPathIds) {
|
||||
// do not delete device collection entries for paths which are
|
||||
// marked for backup. This is to handle "Free up space"
|
||||
// feature, where we delete files which are backed up. Deleting such
|
||||
// entries here result in us losing out on the information that
|
||||
// those folders were marked for automatic backup.
|
||||
await db.execute(
|
||||
'''
|
||||
DELETE FROM device_collections WHERE id = ? AND should_backup = $_sqlBoolFalse;
|
||||
''',
|
||||
[pathID],
|
||||
);
|
||||
await db.execute(
|
||||
'''
|
||||
DELETE FROM device_files WHERE path_id = ?;
|
||||
''',
|
||||
[pathID],
|
||||
);
|
||||
}
|
||||
}
|
||||
return hasUpdated;
|
||||
} catch (e) {
|
||||
_logger.severe("failed to save path names", e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// getDeviceSyncCollectionIDs returns the collectionIDs for the
|
||||
// deviceCollections which are marked for auto-backup
|
||||
Future<Set<int>> getDeviceSyncCollectionIDs() async {
|
||||
final db = await sqliteAsyncDB;
|
||||
final rows = await db.getAll(
|
||||
'''
|
||||
SELECT collection_id FROM device_collections where should_backup =
|
||||
$_sqlBoolTrue
|
||||
and collection_id != -1;
|
||||
''',
|
||||
);
|
||||
final Set<int> result = <int>{};
|
||||
for (final row in rows) {
|
||||
result.add(row['collection_id'] as int);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> updateDevicePathSyncStatus(
|
||||
Map<String, bool> syncStatus,
|
||||
) async {
|
||||
final db = await sqliteAsyncDB;
|
||||
int batchCounter = 0;
|
||||
final parameterSets = <List<Object?>>[];
|
||||
for (MapEntry e in syncStatus.entries) {
|
||||
final String pathID = e.key;
|
||||
parameterSets.add([e.value ? _sqlBoolTrue : _sqlBoolFalse, pathID]);
|
||||
batchCounter++;
|
||||
|
||||
if (batchCounter == 400) {
|
||||
await db.executeBatch(
|
||||
'''
|
||||
UPDATE device_collections SET should_backup = ? WHERE id = ?;
|
||||
''',
|
||||
parameterSets,
|
||||
);
|
||||
parameterSets.clear();
|
||||
batchCounter = 0;
|
||||
}
|
||||
}
|
||||
|
||||
await db.executeBatch(
|
||||
'''
|
||||
UPDATE device_collections SET should_backup = ? WHERE id = ?;
|
||||
''',
|
||||
parameterSets,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateDeviceCollection(
|
||||
String pathID,
|
||||
int collectionID,
|
||||
) async {
|
||||
final db = await sqliteAsyncDB;
|
||||
await db.execute(
|
||||
'''
|
||||
UPDATE device_collections SET collection_id = ? WHERE id = ?;
|
||||
''',
|
||||
[collectionID, pathID],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Future<FileLoadResult> getFilesInDeviceCollection(
|
||||
DeviceCollection deviceCollection,
|
||||
int? ownerID,
|
||||
int startTime,
|
||||
int endTime, {
|
||||
int? limit,
|
||||
bool? asc,
|
||||
}) async {
|
||||
final db = await sqliteAsyncDB;
|
||||
final order = (asc ?? false ? 'ASC' : 'DESC');
|
||||
final String rawQuery = '''
|
||||
SELECT *
|
||||
FROM ${FilesDB.filesTable}
|
||||
WHERE ${FilesDB.columnLocalID} IS NOT NULL AND
|
||||
${FilesDB.columnCreationTime} >= $startTime AND
|
||||
${FilesDB.columnCreationTime} <= $endTime AND
|
||||
(${FilesDB.columnOwnerID} IS NULL OR ${FilesDB.columnOwnerID} =
|
||||
$ownerID ) AND
|
||||
${FilesDB.columnLocalID} IN
|
||||
(SELECT id FROM device_files where path_id = '${deviceCollection.id}' )
|
||||
ORDER BY ${FilesDB.columnCreationTime} $order , ${FilesDB.columnModificationTime} $order
|
||||
''' +
|
||||
(limit != null ? ' limit $limit;' : ';');
|
||||
final results = await db.getAll(rawQuery);
|
||||
final files = convertToFiles(results);
|
||||
final dedupe = deduplicateByLocalID(files);
|
||||
return FileLoadResult(dedupe, files.length == limit);
|
||||
}
|
||||
|
||||
Future<BackedUpFileIDs> getBackedUpForDeviceCollection(
|
||||
String pathID,
|
||||
int ownerID,
|
||||
) async {
|
||||
final db = await sqliteAsyncDB;
|
||||
const String rawQuery = '''
|
||||
SELECT ${FilesDB.columnLocalID}, ${FilesDB.columnUploadedFileID},
|
||||
${FilesDB.columnFileSize}
|
||||
FROM ${FilesDB.filesTable}
|
||||
WHERE ${FilesDB.columnLocalID} IS NOT NULL AND
|
||||
(${FilesDB.columnOwnerID} IS NULL OR ${FilesDB.columnOwnerID} = ?)
|
||||
AND (${FilesDB.columnUploadedFileID} IS NOT NULL AND ${FilesDB.columnUploadedFileID} IS NOT -1)
|
||||
AND
|
||||
${FilesDB.columnLocalID} IN
|
||||
(SELECT id FROM device_files where path_id = ?)
|
||||
''';
|
||||
final results = await db.getAll(rawQuery, [ownerID, pathID]);
|
||||
final localIDs = <String>{};
|
||||
final uploadedIDs = <int>{};
|
||||
int localSize = 0;
|
||||
for (final result in results) {
|
||||
final String localID = result[FilesDB.columnLocalID] as String;
|
||||
final int? fileSize = result[FilesDB.columnFileSize] as int?;
|
||||
if (!localIDs.contains(localID) && fileSize != null) {
|
||||
localSize += fileSize;
|
||||
}
|
||||
localIDs.add(localID);
|
||||
uploadedIDs.add(result[FilesDB.columnUploadedFileID] as int);
|
||||
}
|
||||
return BackedUpFileIDs(localIDs.toList(), uploadedIDs.toList(), localSize);
|
||||
}
|
||||
|
||||
Future<List<DeviceCollection>> getDeviceCollections({
|
||||
bool includeCoverThumbnail = false,
|
||||
}) async {
|
||||
debugPrint(
|
||||
"Fetching DeviceCollections From DB with thumbnail = "
|
||||
"$includeCoverThumbnail",
|
||||
);
|
||||
try {
|
||||
final db = await sqliteAsyncDB;
|
||||
final coverFiles = <EnteFile>[];
|
||||
if (includeCoverThumbnail) {
|
||||
final fileRows = await db.getAll(
|
||||
'''SELECT * FROM FILES where local_id in (select cover_id from device_collections) group by local_id;
|
||||
''',
|
||||
);
|
||||
final files = convertToFiles(fileRows);
|
||||
coverFiles.addAll(files);
|
||||
}
|
||||
final deviceCollectionRows = await db.getAll(
|
||||
'''SELECT * from device_collections''',
|
||||
);
|
||||
final List<DeviceCollection> deviceCollections = [];
|
||||
for (var row in deviceCollectionRows) {
|
||||
final DeviceCollection deviceCollection = DeviceCollection(
|
||||
row["id"] as String,
|
||||
(row['name'] ?? '') as String,
|
||||
count: row['count'] as int,
|
||||
collectionID: (row["collection_id"] ?? -1) as int,
|
||||
coverId: row["cover_id"] as String?,
|
||||
shouldBackup: (row["should_backup"] ?? _sqlBoolFalse) == _sqlBoolTrue,
|
||||
uploadStrategy: getUploadType((row["upload_strategy"] ?? 0) as int),
|
||||
);
|
||||
if (includeCoverThumbnail) {
|
||||
deviceCollection.thumbnail = coverFiles.firstWhereOrNull(
|
||||
(element) => element.localID == deviceCollection.coverId,
|
||||
);
|
||||
if (deviceCollection.thumbnail == null) {
|
||||
final EnteFile? result =
|
||||
await getDeviceCollectionThumbnail(deviceCollection.id);
|
||||
if (result == null) {
|
||||
_logger.info(
|
||||
'Failed to find coverThumbnail for deviceFolder',
|
||||
);
|
||||
continue;
|
||||
} else {
|
||||
deviceCollection.thumbnail = result;
|
||||
}
|
||||
}
|
||||
}
|
||||
deviceCollections.add(deviceCollection);
|
||||
}
|
||||
if (includeCoverThumbnail) {
|
||||
deviceCollections.sort(
|
||||
(a, b) =>
|
||||
b.thumbnail!.creationTime!.compareTo(a.thumbnail!.creationTime!),
|
||||
);
|
||||
}
|
||||
return deviceCollections;
|
||||
} catch (e) {
|
||||
_logger.severe('Failed to getDeviceCollections', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<EnteFile?> getDeviceCollectionThumbnail(String pathID) async {
|
||||
debugPrint("Call fallback method to get potential thumbnail");
|
||||
final db = await sqliteAsyncDB;
|
||||
final fileRows = await db.getAll(
|
||||
'''SELECT * FROM FILES f JOIN device_files df on f.local_id = df.id
|
||||
and df.path_id= ? order by f.creation_time DESC limit 1;
|
||||
''',
|
||||
[pathID],
|
||||
);
|
||||
final files = convertToFiles(fileRows);
|
||||
if (files.isNotEmpty) {
|
||||
return files.first;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _insertBatch(
|
||||
List<List<Object?>> parameterSets,
|
||||
ConflictAlgorithm conflictAlgorithm,
|
||||
) async {
|
||||
final db = await sqliteAsyncDB;
|
||||
await db.executeBatch(
|
||||
'''
|
||||
INSERT OR ${conflictAlgorithm.name.toUpperCase()}
|
||||
INTO device_files (id, path_id) VALUES (?, ?);
|
||||
''',
|
||||
parameterSets,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteBatch(List<List<Object?>> parameterSets) async {
|
||||
final db = await sqliteAsyncDB;
|
||||
await db.executeBatch(
|
||||
'''
|
||||
DELETE FROM device_files WHERE id = ? AND path_id = ?;
|
||||
''',
|
||||
parameterSets,
|
||||
);
|
||||
}
|
||||
}
|
||||
333
mobile/apps/photos/lib/db/local/db.dart
Normal file
@@ -0,0 +1,333 @@
|
||||
import "dart:io";
|
||||
|
||||
import "package:collection/collection.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:path/path.dart";
|
||||
import "package:path_provider/path_provider.dart";
|
||||
import "package:photo_manager/photo_manager.dart";
|
||||
import "package:photos/db/common/base.dart";
|
||||
import "package:photos/db/local/mappers.dart";
|
||||
import "package:photos/db/local/schema.dart";
|
||||
import "package:photos/log/devlog.dart";
|
||||
import 'package:photos/models/file/file.dart';
|
||||
import "package:photos/models/file/mapping/local_mapping.dart";
|
||||
import "package:photos/models/local/local_metadata.dart";
|
||||
import "package:sqlite_async/sqlite_async.dart";
|
||||
|
||||
class LocalDB with SqlDbBase {
|
||||
static const _databaseName = "local_6.db";
|
||||
static const batchInsertMaxCount = 1000;
|
||||
static const _smallTableBatchInsertMaxCount = 5000;
|
||||
late final SqliteDatabase _sqliteDB;
|
||||
SqliteDatabase get sqliteDB => _sqliteDB;
|
||||
|
||||
Future<void> init() async {
|
||||
devLog("LocalDB init");
|
||||
final Directory documentsDirectory =
|
||||
await getApplicationDocumentsDirectory();
|
||||
final String path = join(documentsDirectory.path, _databaseName);
|
||||
|
||||
final db = SqliteDatabase(path: path);
|
||||
await migrate(db, LocalDBMigration.migrationScripts, onForeignKey: true);
|
||||
_sqliteDB = db;
|
||||
debugPrint("LocalDB init complete $path");
|
||||
}
|
||||
|
||||
Future<void> insertAssets(List<AssetEntity> entries) async {
|
||||
if (entries.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.forEach(entries.slices(batchInsertMaxCount), (slice) async {
|
||||
final List<List<Object?>> values =
|
||||
slice.map((e) => LocalDBMappers.assetsRow(e)).toList();
|
||||
await _sqliteDB.executeBatch(
|
||||
'INSERT INTO assets ($assetColumns) values(${getParams(16)}) ON CONFLICT(id) DO UPDATE SET $updateAssetColumns',
|
||||
values,
|
||||
);
|
||||
});
|
||||
debugPrint(
|
||||
'$runtimeType insertAssets complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} assets',
|
||||
);
|
||||
}
|
||||
|
||||
// Store time and location metadata inside edited_assets
|
||||
Future<void> trackEdit(
|
||||
String id,
|
||||
int createdAt,
|
||||
int modifiedAt,
|
||||
double? lat,
|
||||
double? lng,
|
||||
) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await _sqliteDB.execute(
|
||||
'INSERT INTO edited_assets (id, created_at, modified_at, latitude, longitude) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET created_at = ?, modified_at = ?, latitude = ?, longitude = ?',
|
||||
[id, createdAt, modifiedAt, lat, lng, createdAt, modifiedAt, lat, lng],
|
||||
);
|
||||
debugPrint(
|
||||
'$runtimeType editCopy complete in ${stopwatch.elapsed.inMilliseconds}ms for $id',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateMetadata(
|
||||
String id, {
|
||||
DroidMetadata? droid,
|
||||
IOSMetadata? ios,
|
||||
}) async {
|
||||
if (droid != null) {
|
||||
await _sqliteDB.execute(
|
||||
'UPDATE assets SET size = ?, hash = ?, latitude = ?, longitude = ?, created_at = ?, modified_at = ?, scan_state = 1 WHERE id = ?',
|
||||
[
|
||||
droid.size,
|
||||
droid.hash,
|
||||
droid.location?.latitude,
|
||||
droid.location?.longitude,
|
||||
droid.creationTime,
|
||||
droid.modificationTime,
|
||||
id,
|
||||
],
|
||||
);
|
||||
} else if (ios != null) {
|
||||
// await _sqliteDB.execute(
|
||||
// 'UPDATE assets SET size = ?, hash = ?, latitude = ?, longitude = ?, created_at = ?, modified_at = ? WHERE id = ?',
|
||||
// [
|
||||
// ios.size,
|
||||
// ios.hash,
|
||||
// ios.location.latitude,
|
||||
// ios.location.longitude,
|
||||
// ios.creationTime.millisecondsSinceEpoch,
|
||||
// ios.modificationTime.millisecondsSinceEpoch,
|
||||
// ios.id,
|
||||
// ],
|
||||
// );
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, LocalAssetInfo>> getLocalAssetsInfo(
|
||||
List<String> ids,
|
||||
) async {
|
||||
if (ids.isEmpty) return {};
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final result = await _sqliteDB.getAll(
|
||||
'SELECT id, hash, title, relative_path, scan_state FROM assets WHERE id IN (${List.filled(ids.length, "?").join(",")})',
|
||||
ids,
|
||||
);
|
||||
debugPrint(
|
||||
"getLocalAssetsInfo complete in ${stopwatch.elapsed.inMilliseconds}ms for ${ids.length} ids",
|
||||
);
|
||||
return Map.fromEntries(
|
||||
result.map(
|
||||
(row) => MapEntry(row['id'] as String, LocalAssetInfo.fromRow(row)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<EnteFile>> getAssets({LocalAssertsParam? params}) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final result = await _sqliteDB.getAll(
|
||||
"SELECT * FROM assets ${params != null ? params.whereClause(addWhere: true) : ""}",
|
||||
);
|
||||
debugPrint(
|
||||
"getAssets complete in ${stopwatch.elapsed.inMilliseconds}ms, params: ${params?.whereClause()}",
|
||||
);
|
||||
// if time is greater than 1000ms, print explain analyze out
|
||||
if (kDebugMode && stopwatch.elapsed.inMilliseconds > 1000) {
|
||||
final explain = await _sqliteDB.execute(
|
||||
"EXPLAIN QUERY PLAN SELECT * FROM assets ${params != null ? params.whereClause(addWhere: true) : ""}",
|
||||
);
|
||||
debugPrint("getAssets: Explain Query Plan: $explain");
|
||||
}
|
||||
stopwatch.reset();
|
||||
stopwatch.start();
|
||||
final r =
|
||||
result.map((row) => LocalDBMappers.assetRowToEnteFile(row)).toList();
|
||||
debugPrint(
|
||||
"getAssets mapping completed in ${stopwatch.elapsed.inMilliseconds}ms",
|
||||
);
|
||||
return r;
|
||||
}
|
||||
|
||||
Future<List<EnteFile>> getPathAssets(
|
||||
String pathID, {
|
||||
LocalAssertsParam? params,
|
||||
}) async {
|
||||
final String query =
|
||||
"SELECT * FROM assets WHERE id IN (SELECT asset_id FROM device_path_assets WHERE path_id = ?) ${params != null ? 'AND ${params.whereClause()}' : "order by created_at desc"}";
|
||||
debugPrint(query);
|
||||
final result = await _sqliteDB.getAll(
|
||||
query,
|
||||
[pathID],
|
||||
);
|
||||
return result.map((row) => LocalDBMappers.assetRowToEnteFile(row)).toList();
|
||||
}
|
||||
|
||||
Future<void> insertDBPaths(List<AssetPathEntity> entries) async {
|
||||
if (entries.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.forEach(entries.slices(_smallTableBatchInsertMaxCount),
|
||||
(slice) async {
|
||||
final List<List<Object?>> values =
|
||||
slice.map((e) => LocalDBMappers.devicePathRow(e)).toList();
|
||||
await _sqliteDB.executeBatch(
|
||||
'INSERT INTO device_path ($devicePathColumns) values(${getParams(5)}) ON CONFLICT(path_id) DO UPDATE SET $updateDevicePathColumns',
|
||||
values,
|
||||
);
|
||||
});
|
||||
debugPrint(
|
||||
'$runtimeType insertDBPaths complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} paths',
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<AssetPathEntity>> getAssetPaths() async {
|
||||
final result = await _sqliteDB.getAll(
|
||||
"SELECT * FROM device_path",
|
||||
);
|
||||
return result.map((row) => LocalDBMappers.assetPath(row)).toList();
|
||||
}
|
||||
|
||||
Future<void> insertPathToAssetIDs(
|
||||
Map<String, Set<String>> pathToAssetIDs, {
|
||||
bool clearOldMappingsIdsInInput = false,
|
||||
}) async {
|
||||
if (pathToAssetIDs.isEmpty) return;
|
||||
final List<List<String>> allValues = [];
|
||||
pathToAssetIDs.forEach((pathID, assetIDs) {
|
||||
allValues.addAll(assetIDs.map((assetID) => [pathID, assetID]));
|
||||
});
|
||||
if (allValues.isEmpty && !clearOldMappingsIdsInInput) {
|
||||
return;
|
||||
}
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
||||
await _sqliteDB.writeTransaction((tx) async {
|
||||
if (clearOldMappingsIdsInInput) {
|
||||
await tx.execute(
|
||||
"DELETE FROM device_path_assets WHERE path_id IN (${List.generate(pathToAssetIDs.keys.length, (index) => '?').join(',')})",
|
||||
pathToAssetIDs.keys.toList(),
|
||||
);
|
||||
}
|
||||
const int batchSize = 15000;
|
||||
for (int i = 0; i < allValues.length; i += batchSize) {
|
||||
await tx.executeBatch(
|
||||
'INSERT OR REPLACE INTO device_path_assets (path_id, asset_id) VALUES (?, ?)',
|
||||
allValues.sublist(
|
||||
i,
|
||||
i + batchSize > allValues.length ? allValues.length : i + batchSize,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
debugPrint(
|
||||
'$runtimeType insertPathToAssetIDs ${allValues.length} complete in '
|
||||
'${stopwatch.elapsed.inMilliseconds}ms for '
|
||||
'${pathToAssetIDs.length} paths (replaced $clearOldMappingsIdsInInput}',
|
||||
);
|
||||
}
|
||||
|
||||
Future<Set<String>> getAssetsIDs({bool pendingScan = false}) async {
|
||||
final result = await _sqliteDB.getAll(
|
||||
"SELECT id FROM assets ${pendingScan ? 'WHERE scan_state != $finalState ORDER BY created_at DESC' : ''}",
|
||||
);
|
||||
final ids = <String>{};
|
||||
for (var row in result) {
|
||||
ids.add(row["id"] as String);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
Future<Set<String>> getAssetsIDsForPath(
|
||||
String pathID,
|
||||
) async {
|
||||
final result = await _sqliteDB.getAll(
|
||||
"SELECT asset_id FROM device_path_assets WHERE path_id = ? ",
|
||||
[pathID],
|
||||
);
|
||||
final ids = <String>{};
|
||||
for (var row in result) {
|
||||
ids.add(row["asset_id"] as String);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
Future<Map<String, int>> getIDToCreationTime() async {
|
||||
final result = await _sqliteDB.getAll(
|
||||
"SELECT id, created_at FROM assets",
|
||||
);
|
||||
final idToCreationTime = <String, int>{};
|
||||
for (var row in result) {
|
||||
idToCreationTime[row["id"] as String] = row["created_at"] as int;
|
||||
}
|
||||
return idToCreationTime;
|
||||
}
|
||||
|
||||
Future<Map<String, Set<String>>> pathToAssetIDs() async {
|
||||
final result = await _sqliteDB
|
||||
.getAll("SELECT path_id, asset_id FROM device_path_assets");
|
||||
final pathToAssetIDs = <String, Set<String>>{};
|
||||
for (var row in result) {
|
||||
final pathID = row["path_id"] as String;
|
||||
final assetID = row["asset_id"] as String;
|
||||
if (pathToAssetIDs.containsKey(pathID)) {
|
||||
pathToAssetIDs[pathID]!.add(assetID);
|
||||
} else {
|
||||
pathToAssetIDs[pathID] = {assetID};
|
||||
}
|
||||
}
|
||||
return pathToAssetIDs;
|
||||
}
|
||||
|
||||
Future<void> deleteAssets(Set<String> ids) async {
|
||||
if (ids.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await _sqliteDB.execute(
|
||||
'DELETE FROM assets WHERE id IN (${List.filled(ids.length, "?").join(",")})',
|
||||
ids.toList(),
|
||||
);
|
||||
debugPrint(
|
||||
'$runtimeType deleteEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${ids.length} assets entries',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deletePaths(Set<String> pathIds) async {
|
||||
if (pathIds.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await _sqliteDB.execute(
|
||||
'DELETE FROM device_path WHERE path_id IN (${List.filled(pathIds.length, "?").join(",")})',
|
||||
pathIds.toList(),
|
||||
);
|
||||
debugPrint(
|
||||
'$runtimeType deleteEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${pathIds.length} path entries',
|
||||
);
|
||||
}
|
||||
|
||||
// returns true if either asset queue or shared_assets has any entry for given ownerID
|
||||
Future<bool> hasAssetQueueOrSharedAsset(int ownerID) async {
|
||||
final result = await _sqliteDB.getAll(
|
||||
'''
|
||||
SELECT 1 FROM asset_upload_queue WHERE owner_id = ?
|
||||
UNION ALL
|
||||
SELECT 1 FROM shared_assets WHERE owner_id = ?
|
||||
LIMIT 1
|
||||
''',
|
||||
[ownerID, ownerID],
|
||||
);
|
||||
return result.isNotEmpty;
|
||||
}
|
||||
|
||||
Future<(int, int)> getUniqueQueueAndSharedAssetsCount(
|
||||
int ownerID,
|
||||
) async {
|
||||
final queuedAssets = await _sqliteDB.getAll(
|
||||
'SELECT COUNT(distinct asset_id) as count FROM asset_upload_queue WHERE owner_id = ?',
|
||||
[ownerID],
|
||||
);
|
||||
final sharedAssets = await _sqliteDB.getAll(
|
||||
'SELECT COUNT(*) as count FROM shared_assets WHERE owner_id = ?',
|
||||
[ownerID],
|
||||
);
|
||||
final queuedCount =
|
||||
queuedAssets.isNotEmpty ? (queuedAssets.first['count'] as int) : 0;
|
||||
final sharedCount =
|
||||
sharedAssets.isNotEmpty ? (sharedAssets.first['count'] as int) : 0;
|
||||
return (queuedCount, sharedCount);
|
||||
}
|
||||
}
|
||||
100
mobile/apps/photos/lib/db/local/mappers.dart
Normal file
@@ -0,0 +1,100 @@
|
||||
import "dart:io";
|
||||
|
||||
import "package:photo_manager/photo_manager.dart";
|
||||
import "package:photos/models/file/file.dart";
|
||||
|
||||
class LocalDBMappers {
|
||||
const LocalDBMappers._();
|
||||
|
||||
static List<Object?> assetsRow(AssetEntity entity) {
|
||||
return [
|
||||
entity.id,
|
||||
entity.type.index,
|
||||
entity.subtype,
|
||||
entity.width,
|
||||
entity.height,
|
||||
entity.duration,
|
||||
entity.orientation,
|
||||
entity.isFavorite ? 1 : 0,
|
||||
entity.title,
|
||||
entity.relativePath,
|
||||
entity.createDateTime.microsecondsSinceEpoch,
|
||||
entity.modifiedDateTime.microsecondsSinceEpoch,
|
||||
entity.mimeType,
|
||||
entity.latitude,
|
||||
entity.longitude,
|
||||
0, // scan_state
|
||||
];
|
||||
}
|
||||
|
||||
static AssetEntity asset(Map<String, dynamic> row) {
|
||||
return AssetEntity(
|
||||
id: row['id'] as String,
|
||||
typeInt: row['type'] as int,
|
||||
subtype: row['sub_type'] as int,
|
||||
width: row['width'] as int,
|
||||
height: row['height'] as int,
|
||||
duration: row['duration_in_sec'] as int,
|
||||
orientation: row['orientation'] as int,
|
||||
isFavorite: (row['is_fav'] as int) == 1,
|
||||
title: row['title'] as String?,
|
||||
relativePath: row['relative_path'] as String?,
|
||||
createDateSecond: (row['created_at'] as int) ~/ 1000000,
|
||||
modifiedDateSecond: (row['modified_at'] as int) ~/ 1000000,
|
||||
mimeType: row['mime_type'] as String?,
|
||||
latitude: row['latitude'] as double?,
|
||||
longitude: row['longitude'] as double?,
|
||||
);
|
||||
}
|
||||
|
||||
static EnteFile assetRowToEnteFile(Map<String, dynamic> row) {
|
||||
final asset = AssetEntity(
|
||||
id: row['id'] as String,
|
||||
typeInt: row['type'] as int,
|
||||
subtype: row['sub_type'] as int,
|
||||
width: row['width'] as int,
|
||||
height: row['height'] as int,
|
||||
duration: row['duration_in_sec'] as int,
|
||||
orientation: row['orientation'] as int,
|
||||
isFavorite: (row['is_fav'] as int) == 1,
|
||||
title: row['title'] as String?,
|
||||
relativePath: row['relative_path'] as String?,
|
||||
createDateSecond: (row['created_at'] as int) ~/ 1000000,
|
||||
modifiedDateSecond: (row['modified_at'] as int) ~/ 1000000,
|
||||
mimeType: row['mime_type'] as String?,
|
||||
latitude: row['latitude'] as double?,
|
||||
longitude: row['longitude'] as double?,
|
||||
);
|
||||
return EnteFile.fromAssetSync(asset);
|
||||
}
|
||||
|
||||
static List<Object?> devicePathRow(AssetPathEntity entity) {
|
||||
return [
|
||||
entity.id,
|
||||
entity.name,
|
||||
entity.albumType,
|
||||
entity.albumTypeEx?.darwin?.type?.index,
|
||||
entity.albumTypeEx?.darwin?.subtype?.index,
|
||||
];
|
||||
}
|
||||
|
||||
static AssetPathEntity assetPath(Map<String, dynamic> row) {
|
||||
return AssetPathEntity(
|
||||
id: row['path_id'] as String,
|
||||
name: row['name'] as String,
|
||||
albumType: row['album_type'] as int,
|
||||
albumTypeEx: AlbumType(
|
||||
darwin: !Platform.isAndroid
|
||||
? DarwinAlbumType(
|
||||
type: PMDarwinAssetCollectionTypeExt.fromValue(
|
||||
row['ios_album_type'] as int?,
|
||||
),
|
||||
subtype: PMDarwinAssetCollectionSubtypeExt.fromValue(
|
||||
row['darwin_subtype'] as int?,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
253
mobile/apps/photos/lib/db/local/schema.dart
Normal file
@@ -0,0 +1,253 @@
|
||||
import "dart:io";
|
||||
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:sqlite_async/sqlite_async.dart";
|
||||
|
||||
const assetColumns =
|
||||
"id, type, sub_type, width, height, duration_in_sec, orientation, is_fav, title, relative_path, created_at, modified_at, mime_type, latitude, longitude, scan_state";
|
||||
|
||||
const assetUploadQueueColumns =
|
||||
"dest_collection_id, asset_id, path_id, owner_id, manual";
|
||||
const androidAssetState = 1;
|
||||
const androidHashState = 1 << 2;
|
||||
const androidMediaType = 1 << 3;
|
||||
const iOSAssetState = 1;
|
||||
const iOSCloudIdState = 1 << 2;
|
||||
const iOSAssetHashState = 1 << 3;
|
||||
|
||||
final finalState = Platform.isAndroid
|
||||
? (androidAssetState ^ androidHashState ^ androidMediaType)
|
||||
: (iOSAssetState ^ iOSCloudIdState ^ iOSAssetHashState);
|
||||
// Generate the update clause dynamically (excludes 'id')
|
||||
final String updateAssetColumns = assetColumns
|
||||
.split(', ')
|
||||
.where((column) => column != 'id') // Exclude primary key from update
|
||||
.map((column) => '$column = excluded.$column') // Use excluded virtual table
|
||||
.join(', ');
|
||||
|
||||
const devicePathColumns =
|
||||
"path_id, name, album_type, ios_album_type, ios_album_subtype";
|
||||
|
||||
final String updateDevicePathColumns = devicePathColumns
|
||||
.split(', ')
|
||||
.where((column) => column != 'path_id') // Exclude primary key from update
|
||||
.map((column) => '$column = excluded.$column') // Use excluded virtual table
|
||||
.join(', ');
|
||||
|
||||
const String deviceCollectionWithOneAssetQuery = '''
|
||||
WITH latest_per_path AS (
|
||||
SELECT
|
||||
dpa.path_id,
|
||||
MAX(a.created_at) as max_created,
|
||||
count(*) as asset_count
|
||||
FROM
|
||||
device_path_assets dpa
|
||||
JOIN
|
||||
assets a ON dpa.asset_id = a.id
|
||||
|
||||
GROUP BY
|
||||
dpa.path_id
|
||||
),
|
||||
ranked_assets AS (
|
||||
SELECT
|
||||
dpa.path_id,
|
||||
a.*,
|
||||
ROW_NUMBER() OVER (PARTITION BY dpa.path_id ORDER BY a.id) as rn,
|
||||
lpp.asset_count
|
||||
FROM
|
||||
device_path_assets dpa
|
||||
JOIN
|
||||
assets a ON dpa.asset_id = a.id
|
||||
JOIN
|
||||
latest_per_path lpp ON dpa.path_id = lpp.path_id AND a.created_at = lpp.max_created
|
||||
)
|
||||
SELECT
|
||||
dp.*,
|
||||
ra.*,
|
||||
pc.*
|
||||
FROM
|
||||
device_path dp
|
||||
JOIN
|
||||
ranked_assets ra ON dp.path_id = ra.path_id AND ra.rn = 1
|
||||
LEFT JOIN path_backup_config pc
|
||||
on dp.path_id = pc.device_path_id
|
||||
''';
|
||||
|
||||
class LocalAssertsParam {
|
||||
int? limit;
|
||||
int? offset;
|
||||
String? orderByColumn;
|
||||
bool isAsc;
|
||||
(int?, int?)? createAtRange;
|
||||
|
||||
LocalAssertsParam({
|
||||
this.limit,
|
||||
this.offset,
|
||||
this.orderByColumn = "created_at",
|
||||
this.isAsc = false,
|
||||
this.createAtRange,
|
||||
});
|
||||
|
||||
String get orderBy => orderByColumn == null
|
||||
? ""
|
||||
: "ORDER BY $orderByColumn ${isAsc ? "ASC" : "DESC"}";
|
||||
|
||||
String get limitOffset => (limit != null && offset != null)
|
||||
? "LIMIT $limit + OFFSET $offset)"
|
||||
: (limit != null)
|
||||
? "LIMIT $limit"
|
||||
: "";
|
||||
|
||||
String get createAtRangeStr => (createAtRange == null ||
|
||||
createAtRange!.$1 == null)
|
||||
? ""
|
||||
: "(created_at BETWEEN ${createAtRange!.$1} AND ${createAtRange!.$2})";
|
||||
|
||||
String whereClause({bool addWhere = false}) {
|
||||
final where = <String>[];
|
||||
if (createAtRangeStr.isNotEmpty) {
|
||||
where.add(createAtRangeStr);
|
||||
}
|
||||
|
||||
return (where.isEmpty
|
||||
? ""
|
||||
: '${addWhere ? "Where" : ""} ${where.join(" AND ")}') +
|
||||
" " +
|
||||
orderBy +
|
||||
" " +
|
||||
limitOffset;
|
||||
}
|
||||
}
|
||||
|
||||
class LocalDBMigration {
|
||||
static const migrationScripts = [
|
||||
'''
|
||||
CREATE TABLE assets (
|
||||
id TEXT PRIMARY KEY,
|
||||
type INTEGER NOT NULL,
|
||||
sub_type INTEGER NOT NULL,
|
||||
width INTEGER NOT NULL,
|
||||
height INTEGER NOT NULL,
|
||||
duration_in_sec INTEGER NOT NULL,
|
||||
orientation INTEGER NOT NULL,
|
||||
is_fav INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
relative_path TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
modified_at INTEGER NOT NULL,
|
||||
mime_type TEXT,
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
scan_state INTEGER DEFAULT 0,
|
||||
hash TEXT,
|
||||
size INTEGER,
|
||||
os_metadata TEXT DEFAULT '{}'
|
||||
);
|
||||
''',
|
||||
'''
|
||||
CREATE INDEX IF NOT EXISTS assets_created_at ON assets(created_at);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE shared_assets (
|
||||
dest_collection_id INTEGER NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
duration_in_sec INTEGER DEFAULT 0,
|
||||
owner_id INTEGER NOT NULL,
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
PRIMARY KEY (dest_collection_id, id)
|
||||
);
|
||||
''',
|
||||
'''
|
||||
CREATE INDEX IF NOT EXISTS sa_collection_owner ON shared_assets(dest_collection_id, owner_id);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE device_path (
|
||||
path_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
album_type INTEGER NOT NULL,
|
||||
ios_album_type INTEGER,
|
||||
ios_album_subtype INTEGER
|
||||
);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE device_path_assets (
|
||||
path_id TEXT NOT NULL,
|
||||
asset_id TEXT NOT NULL,
|
||||
PRIMARY KEY (path_id, asset_id),
|
||||
FOREIGN KEY (path_id) REFERENCES device_path(path_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE
|
||||
);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE queue (
|
||||
id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
PRIMARY KEY (id, name)
|
||||
);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE path_backup_config(
|
||||
device_path_id TEXT PRIMARY KEY,
|
||||
owner_id INTEGER NOT NULL,
|
||||
collection_id INTEGER,
|
||||
should_backup INTEGER NOT NULL DEFAULT 0,
|
||||
upload_strategy INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE asset_upload_queue (
|
||||
dest_collection_id INTEGER NOT NULL,
|
||||
asset_id TEXT NOT NULL,
|
||||
path_id TEXT,
|
||||
owner_id INTEGER NOT NULL,
|
||||
manual INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (dest_collection_id, asset_id),
|
||||
FOREIGN KEY(asset_id) REFERENCES assets(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_asset_upload_queue_owner_id
|
||||
ON asset_upload_queue(owner_id)
|
||||
WHERE owner_id IS NOT NULL;
|
||||
''',
|
||||
'''
|
||||
CREATE INDEX IF NOT EXISTS assets_created_at_desc ON assets(created_at DESC);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE edited_assets (
|
||||
id String NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
modified_at INTEGER NOT NULL,
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
PRIMARY KEY (id)
|
||||
FOREIGN KEY (id) REFERENCES assets(id) ON DELETE CASCADE
|
||||
);
|
||||
''',
|
||||
];
|
||||
|
||||
static Future<void> migrate(
|
||||
SqliteDatabase database,
|
||||
) async {
|
||||
final result = await database.execute('PRAGMA user_version');
|
||||
await database.execute("PRAGMA foreign_keys = ON");
|
||||
final currentVersion = result[0]['user_version'] as int;
|
||||
final toVersion = migrationScripts.length;
|
||||
|
||||
if (currentVersion < toVersion) {
|
||||
debugPrint("Migrating Local DB from $currentVersion to $toVersion");
|
||||
await database.writeTransaction((tx) async {
|
||||
for (int i = currentVersion + 1; i <= toVersion; i++) {
|
||||
await tx.execute(migrationScripts[i - 1]);
|
||||
}
|
||||
await tx.execute('PRAGMA user_version = $toVersion');
|
||||
});
|
||||
} else if (currentVersion > toVersion) {
|
||||
throw AssertionError(
|
||||
"currentVersion($currentVersion) cannot be greater than toVersion($toVersion)",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
33
mobile/apps/photos/lib/db/local/table/device_albums.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
import "package:photo_manager/photo_manager.dart";
|
||||
import "package:photos/db/local/db.dart";
|
||||
import "package:photos/db/local/mappers.dart";
|
||||
import "package:photos/db/local/schema.dart";
|
||||
import "package:photos/models/device_collection.dart";
|
||||
import "package:photos/models/file/file.dart";
|
||||
import "package:photos/models/upload_strategy.dart";
|
||||
|
||||
extension DeviceAlbums on LocalDB {
|
||||
Future<List<DeviceCollection>> getDeviceCollections() async {
|
||||
final List<DeviceCollection> collections = [];
|
||||
final rows = await sqliteDB.getAll(deviceCollectionWithOneAssetQuery);
|
||||
for (final row in rows) {
|
||||
final path = LocalDBMappers.assetPath(row);
|
||||
AssetEntity? asset;
|
||||
if (row['id'] != null) {
|
||||
asset = LocalDBMappers.asset(row);
|
||||
}
|
||||
collections.add(
|
||||
DeviceCollection(
|
||||
path,
|
||||
count: row['asset_count'] as int,
|
||||
thumbnail: asset != null ? EnteFile.fromAssetSync(asset) : null,
|
||||
shouldBackup: (row['should_backup'] ?? 0) as int == 1,
|
||||
uploadStrategy:
|
||||
UploadStrategy.values[(row['upload_strategy'] ?? 0) as int],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return collections;
|
||||
}
|
||||
}
|
||||
92
mobile/apps/photos/lib/db/local/table/path_config_table.dart
Normal file
@@ -0,0 +1,92 @@
|
||||
import "package:collection/collection.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:photos/db/local/db.dart";
|
||||
import "package:photos/log/devlog.dart";
|
||||
import "package:photos/models/local/path_config.dart";
|
||||
import "package:photos/models/upload_strategy.dart";
|
||||
|
||||
extension PathBackupConfigTable on LocalDB {
|
||||
Future<void> insertOrUpdatePathConfigs(
|
||||
Map<String, bool> pathConfigs,
|
||||
int ownerID,
|
||||
) async {
|
||||
if (pathConfigs.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.forEach(
|
||||
pathConfigs.entries.slices(LocalDB.batchInsertMaxCount), (slice) async {
|
||||
final List<List<Object?>> values =
|
||||
slice.map((e) => [e.key, e.value ? 1 : 0, ownerID]).toList();
|
||||
await sqliteDB.executeBatch(
|
||||
'INSERT INTO path_backup_config (device_path_id, should_backup, owner_id) VALUES (?, ?, ?) ON CONFLICT(device_path_id) DO UPDATE SET should_backup = ?, owner_id = ?',
|
||||
values.map((e) => [e[0], e[1], e[2], e[1], e[2]]).toList(),
|
||||
);
|
||||
});
|
||||
debugPrint(
|
||||
'$runtimeType insertOrUpdatePathConfigs complete in ${stopwatch.elapsed.inMilliseconds}ms for ${pathConfigs.length} paths',
|
||||
);
|
||||
}
|
||||
|
||||
Future<Set<String>> getBackedUpPathIDs(int ownerID) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT device_path_id FROM path_backup_config WHERE should_backup = 1 AND owner_id = ?',
|
||||
[ownerID],
|
||||
);
|
||||
final paths = result.map((row) => row['device_path_id'] as String).toSet();
|
||||
devLog(
|
||||
'$runtimeType getPathsWithBackupEnabled complete in ${stopwatch.elapsed.inMilliseconds}ms',
|
||||
name: 'getPathsWithBackupEnabled',
|
||||
);
|
||||
return paths;
|
||||
}
|
||||
|
||||
// destCollectionWithBackup returns the non-null collection ids
|
||||
// for given ownerID for paths that have backup enabled.
|
||||
Future<Set<int>> destCollectionWithBackup(int ownerID) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT collection_id FROM path_backup_config WHERE should_backup = 1 AND owner_id = ? AND collection_id IS NOT NULL',
|
||||
[ownerID],
|
||||
);
|
||||
final Set<int> collectionIDs =
|
||||
result.map((row) => row['collection_id'] as int).whereNotNull().toSet();
|
||||
devLog(
|
||||
'$runtimeType destCollectionWithBackup complete in ${stopwatch.elapsed.inMilliseconds}ms',
|
||||
name: 'destCollectionWithBackup',
|
||||
);
|
||||
return collectionIDs;
|
||||
}
|
||||
|
||||
Future<void> updateDestConnection(
|
||||
String pathID,
|
||||
int destCollection,
|
||||
int ownerID,
|
||||
) async {
|
||||
await sqliteDB.execute(
|
||||
'UPDATE path_backup_config SET collection_id = ? WHERE device_path_id = ? AND owner_id = ?',
|
||||
[destCollection, pathID, ownerID],
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<PathConfig>> getPathConfigs(int ownerID) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT * FROM path_backup_config WHERE owner_id = ?',
|
||||
[ownerID],
|
||||
);
|
||||
final configs = result.map((row) {
|
||||
return PathConfig(
|
||||
row['device_path_id'] as String,
|
||||
row['owner_id'] as int,
|
||||
row['collection_id'] as int?,
|
||||
(row['should_backup'] as int) == 1,
|
||||
getUploadType(row['upload_strategy'] as int),
|
||||
);
|
||||
}).toList();
|
||||
devLog(
|
||||
'$runtimeType getPathConfigs complete in ${stopwatch.elapsed.inMilliseconds}ms',
|
||||
name: 'getPathConfigs',
|
||||
);
|
||||
return configs;
|
||||
}
|
||||
}
|
||||
69
mobile/apps/photos/lib/db/local/table/shared_assets.dart
Normal file
@@ -0,0 +1,69 @@
|
||||
import "package:collection/collection.dart";
|
||||
import "package:photos/db/local/db.dart";
|
||||
import "package:photos/models/local/shared_asset.dart";
|
||||
|
||||
extension SharedAssetsTable on LocalDB {
|
||||
Future<Set<String>> getSharedAssetsID() async {
|
||||
final result = await sqliteDB.getAll('SELECT id FROM shared_assets');
|
||||
return Set.unmodifiable(result.map<String>((row) => row['id'] as String));
|
||||
}
|
||||
|
||||
Future<void> insertSharedAssets(List<SharedAsset> assets) async {
|
||||
if (assets.isEmpty) return;
|
||||
await Future.forEach(
|
||||
assets.slices(LocalDB.batchInsertMaxCount),
|
||||
(slice) async {
|
||||
final List<List<Object?>> values =
|
||||
slice.map((e) => e.rowProps).toList();
|
||||
await sqliteDB.executeBatch(
|
||||
'INSERT INTO shared_assets (id, name, type, creation_time, duration_in_seconds, dest_collection_id, owner_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
values,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<SharedAsset>> getSharedAssets() async {
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT * FROM shared_assets ORDER BY creation_time DESC',
|
||||
);
|
||||
return result.map((row) => SharedAsset.fromRow(row)).toList();
|
||||
}
|
||||
|
||||
Future<List<SharedAsset>> getSharedAssetsByCollection(
|
||||
int collectionID,
|
||||
) async {
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT * FROM shared_assets WHERE dest_collection_id = ? ORDER BY creation_time DESC',
|
||||
[collectionID],
|
||||
);
|
||||
return result.map((row) => SharedAsset.fromRow(row)).toList();
|
||||
}
|
||||
|
||||
Future<void> deleteSharedAssetsByCollection(int collectionID) async {
|
||||
await sqliteDB.execute(
|
||||
'DELETE FROM shared_assets WHERE dest_collection_id = ?',
|
||||
[collectionID],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteSharedAsset(String assetID) async {
|
||||
await sqliteDB.execute(
|
||||
'DELETE FROM shared_assets WHERE id = ?',
|
||||
[assetID],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteSharedAssets(Set<String> assetIDs) async {
|
||||
if (assetIDs.isEmpty) return;
|
||||
await Future.forEach(
|
||||
assetIDs.slices(LocalDB.batchInsertMaxCount),
|
||||
(slice) async {
|
||||
await sqliteDB.executeBatch(
|
||||
'DELETE FROM shared_assets WHERE id = ?',
|
||||
slice.map((id) => [id]).toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
148
mobile/apps/photos/lib/db/local/table/upload_queue_table.dart
Normal file
@@ -0,0 +1,148 @@
|
||||
import "package:collection/collection.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:photos/db/local/db.dart";
|
||||
import "package:photos/db/local/mappers.dart";
|
||||
import "package:photos/db/local/schema.dart";
|
||||
import "package:photos/models/file/file.dart";
|
||||
import "package:photos/models/local/asset_upload_queue.dart";
|
||||
|
||||
extension UploadQueueTable on LocalDB {
|
||||
Future<Set<String>> getQueueAssetIDs(int ownerID) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT asset_id FROM asset_upload_queue WHERE owner_id = ?',
|
||||
[ownerID],
|
||||
);
|
||||
final assetIDs = result.map((row) => row['asset_id'] as String).toSet();
|
||||
debugPrint(
|
||||
'$runtimeType getQueueAssetIDs complete in ${stopwatch.elapsed.inMilliseconds}ms',
|
||||
);
|
||||
return assetIDs;
|
||||
}
|
||||
|
||||
Future<void> clearMappingsWithDiffPath(
|
||||
int ownerID,
|
||||
Set<String> pathIDs,
|
||||
) async {
|
||||
if (pathIDs.isEmpty) {
|
||||
// delete all mapping with path ids
|
||||
await sqliteDB.execute(
|
||||
'DELETE FROM asset_upload_queue WHERE owner_id = ? AND path_id IS NOT NULL',
|
||||
[ownerID],
|
||||
);
|
||||
} else {
|
||||
// delete mappings where path_id is not null and not in pathIDs
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await sqliteDB.execute(
|
||||
'DELETE FROM asset_upload_queue WHERE owner_id = ? AND path_id IS NOT NULL AND path_id NOT IN (${pathIDs.map((_) => '?').join(',')})',
|
||||
[ownerID, ...pathIDs],
|
||||
);
|
||||
debugPrint(
|
||||
'$runtimeType clearMappingsWithDiffPath complete in ${stopwatch.elapsed.inMilliseconds}ms for ${pathIDs.length} paths',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> existsQueueEntry(AssetUploadQueue entry) async {
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT 1 FROM asset_upload_queue WHERE asset_id = ? AND owner_id = ? AND dest_collection_id = ?',
|
||||
[entry.id, entry.ownerId, entry.destCollectionId],
|
||||
);
|
||||
return result.isNotEmpty;
|
||||
}
|
||||
|
||||
Future<int> delete(AssetUploadQueue entry) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final result = await sqliteDB.execute(
|
||||
'DELETE FROM asset_upload_queue WHERE asset_id = ? AND owner_id = ? and dest_collection_id = ?',
|
||||
[entry.id, entry.ownerId, entry.destCollectionId],
|
||||
);
|
||||
debugPrint(
|
||||
'$runtimeType delete complete in ${stopwatch.elapsed.inMilliseconds}ms for entry: $entry',
|
||||
);
|
||||
return result.isNotEmpty ? result[0]['changes'] as int : 0;
|
||||
}
|
||||
|
||||
Future<List<(AssetUploadQueue, EnteFile)>> getQueueEntriesWithFiles(
|
||||
int ownerID, {
|
||||
int? destCollection,
|
||||
}) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT asset_upload_queue.*, assets.* FROM asset_upload_queue JOIN assets ON assets.id = asset_upload_queue.asset_id WHERE owner_id = ? ${destCollection != null ? 'AND dest_collection_id = ?' : ''} ORDER BY created_at DESC',
|
||||
destCollection != null ? [ownerID, destCollection] : [ownerID],
|
||||
);
|
||||
final entries = result
|
||||
.map(
|
||||
(row) => (
|
||||
AssetUploadQueue(
|
||||
id: row['asset_id'] as String,
|
||||
pathId: row['path_id'] as String?,
|
||||
destCollectionId: row['dest_collection_id'] as int,
|
||||
ownerId: row['owner_id'] as int,
|
||||
manual: (row['manual'] as int) == 1,
|
||||
),
|
||||
EnteFile.fromAssetSync(LocalDBMappers.asset(row)),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
debugPrint(
|
||||
'$runtimeType getQueueEntriesWithFiles complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries',
|
||||
);
|
||||
return entries;
|
||||
}
|
||||
|
||||
Future<List<AssetUploadQueue>> getQueueEntries(
|
||||
int ownerID, {
|
||||
int? destCollection,
|
||||
}) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT asset_upload_queue.*, assets.* FROM asset_upload_queue JOIN assets ON assets.id = asset_upload_queue.asset_id WHERE owner_id = ? ${destCollection != null ? 'AND dest_collection_id = ?' : ''} ORDER BY created_at DESC',
|
||||
destCollection != null ? [ownerID, destCollection] : [ownerID],
|
||||
);
|
||||
final entries = result
|
||||
.map(
|
||||
(row) => AssetUploadQueue(
|
||||
id: row['asset_id'] as String,
|
||||
pathId: row['path_id'] as String?,
|
||||
destCollectionId: row['dest_collection_id'] as int,
|
||||
ownerId: row['owner_id'] as int,
|
||||
manual: (row['manual'] as int) == 1,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
debugPrint(
|
||||
'$runtimeType getQueueEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries',
|
||||
);
|
||||
return entries;
|
||||
}
|
||||
|
||||
Future<void> insertOrUpdateQueue(
|
||||
Set<String> assetIDs,
|
||||
int destCollection,
|
||||
int ownerID, {
|
||||
String? path,
|
||||
bool manual = false,
|
||||
}) async {
|
||||
if (assetIDs.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.forEach(
|
||||
assetIDs.slices(LocalDB.batchInsertMaxCount),
|
||||
(slice) async {
|
||||
final List<List<Object?>> values = slice
|
||||
.map((e) => [destCollection, e, path, ownerID, manual])
|
||||
.toList();
|
||||
await sqliteDB.executeBatch(
|
||||
'INSERT INTO asset_upload_queue ($assetUploadQueueColumns) VALUES(?,?,?,?,?) ON CONFLICT DO UPDATE SET manual = ?, path_id = ?',
|
||||
values
|
||||
.map((e) => [e[0], e[1], e[2], e[3], e[4], manual, path])
|
||||
.toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
debugPrint(
|
||||
'$runtimeType insertOrUpdateQueue complete in ${stopwatch.elapsed.inMilliseconds}ms for ${assetIDs.length} items',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import "package:photos/services/machine_learning/face_ml/face_clustering/face_db
|
||||
abstract class IMLDataDB<T> {
|
||||
Future<void> bulkInsertFaces(List<Face> faces);
|
||||
Future<void> updateFaceIdToClusterId(Map<String, String> faceIDToClusterID);
|
||||
Future<Map<int, int>> faceIndexedFileIds({int minimumMlVersion});
|
||||
Future<Map<T, int>> faceIndexedFileIds({int minimumMlVersion});
|
||||
Future<int> getFaceIndexedFileCount({int minimumMlVersion});
|
||||
Future<Map<String, int>> clusterIdToFaceCount();
|
||||
Future<Set<String>> getPersonIgnoredClusters(String personID);
|
||||
@@ -52,7 +52,7 @@ abstract class IMLDataDB<T> {
|
||||
Future<void> forceUpdateClusterIds(Map<String, String> faceIDToClusterID);
|
||||
Future<void> removeFaceIdToClusterId(Map<String, String> faceIDToClusterID);
|
||||
Future<void> removePerson(String personID);
|
||||
Future<List<FaceDbInfoForClustering>> getFaceInfoForClustering({
|
||||
Future<List<FaceDbInfoForClustering<T>>> getFaceInfoForClustering({
|
||||
int maxFaces,
|
||||
int offset,
|
||||
int batchSize,
|
||||
@@ -112,9 +112,9 @@ abstract class IMLDataDB<T> {
|
||||
});
|
||||
|
||||
Future<List<EmbeddingVector>> getAllClipVectors();
|
||||
Future<Map<int, int>> clipIndexedFileWithVersion();
|
||||
Future<Map<T, int>> clipIndexedFileWithVersion();
|
||||
Future<int> getClipIndexedFileCount({int minimumMlVersion});
|
||||
Future<void> putClip(List<ClipEmbedding> embeddings);
|
||||
Future<void> putClip<T>(List<ClipEmbedding<T>> embeddings);
|
||||
Future<void> deleteClipEmbeddings(List<T> fileIDs);
|
||||
Future<void> deleteClipIndexes();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -53,13 +53,13 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
static final MLDataDB instance = MLDataDB._privateConstructor();
|
||||
|
||||
static final _migrationScripts = [
|
||||
createFacesTable,
|
||||
getCreateFacesTable(false),
|
||||
createFaceClustersTable,
|
||||
createClusterPersonTable,
|
||||
createClusterSummaryTable,
|
||||
createNotPersonFeedbackTable,
|
||||
fcClusterIDIndex,
|
||||
createClipEmbeddingsTable,
|
||||
getCreateClipEmbeddingsTable(false),
|
||||
createFileDataTable,
|
||||
createFaceCacheTable,
|
||||
];
|
||||
@@ -80,10 +80,10 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
final asyncDBConnection =
|
||||
SqliteDatabase(path: databaseDirectory, maxReaders: 2);
|
||||
final stopwatch = Stopwatch()..start();
|
||||
_logger.info("MLDataDB: Starting migration");
|
||||
_logger.info("$runtimeType: Starting migration");
|
||||
await migrate(asyncDBConnection, _migrationScripts);
|
||||
_logger.info(
|
||||
"MLDataDB Migration took ${stopwatch.elapsedMilliseconds} ms",
|
||||
"$runtimeType Migration took ${stopwatch.elapsedMilliseconds} ms",
|
||||
);
|
||||
stopwatch.stop();
|
||||
|
||||
@@ -360,10 +360,10 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
(element) => (element[fileIDColumn] as int) == avatarFileId,
|
||||
);
|
||||
if (row != null) {
|
||||
return mapRowToFace(row);
|
||||
return mapRowToFace<int>(row);
|
||||
}
|
||||
}
|
||||
return mapRowToFace(faceMaps.first);
|
||||
return mapRowToFace<int>(faceMaps.first);
|
||||
}
|
||||
}
|
||||
if (clusterID != null) {
|
||||
@@ -411,7 +411,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
if (maps.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return maps.map((e) => mapRowToFace(e)).toList();
|
||||
return maps.map((e) => mapRowToFace<int>(e)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -428,7 +428,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
}
|
||||
final result = <int, List<FaceWithoutEmbedding>>{};
|
||||
for (final map in maps) {
|
||||
final face = mapRowToFaceWithoutEmbedding(map);
|
||||
final face = mapRowToFaceWithoutEmbedding<int>(map);
|
||||
final fileID = map[fileIDColumn] as int;
|
||||
result.putIfAbsent(fileID, () => <FaceWithoutEmbedding>[]).add(face);
|
||||
}
|
||||
@@ -726,7 +726,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<FaceDbInfoForClustering>> getFaceInfoForClustering({
|
||||
Future<List<FaceDbInfoForClustering<int>>> getFaceInfoForClustering({
|
||||
int maxFaces = 20000,
|
||||
int offset = 0,
|
||||
int batchSize = 10000,
|
||||
@@ -738,7 +738,8 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
);
|
||||
final db = await instance.asyncDB;
|
||||
|
||||
final List<FaceDbInfoForClustering> result = <FaceDbInfoForClustering>[];
|
||||
final List<FaceDbInfoForClustering<int>> result =
|
||||
<FaceDbInfoForClustering<int>>[];
|
||||
while (true) {
|
||||
// Query a batch of rows
|
||||
final List<Map<String, dynamic>> maps = await db.getAll(
|
||||
@@ -758,7 +759,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
final faceIdToClusterId = await getFaceIdsToClusterIds(faceIds);
|
||||
for (final map in maps) {
|
||||
final faceID = map[faceIDColumn] as String;
|
||||
final faceInfo = FaceDbInfoForClustering(
|
||||
final faceInfo = FaceDbInfoForClustering<int>(
|
||||
faceID: faceID,
|
||||
clusterId: faceIdToClusterId[faceID],
|
||||
embeddingBytes: map[embeddingColumn] as Uint8List,
|
||||
@@ -1135,7 +1136,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
final db = await instance.asyncDB;
|
||||
if (faces) {
|
||||
await db.execute(deleteFacesTable);
|
||||
await db.execute(createFacesTable);
|
||||
await db.execute(getCreateFacesTable(false));
|
||||
await db.execute(deleteFaceClustersTable);
|
||||
await db.execute(createFaceClustersTable);
|
||||
await db.execute(fcClusterIDIndex);
|
||||
@@ -1335,8 +1336,8 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
"Got ${fileIDs.length} valid embeddings, $weirdCount weird embeddings",
|
||||
);
|
||||
|
||||
await ClipVectorDB.instance
|
||||
.bulkInsertEmbeddings(fileIDs: fileIDs, embeddings: embeddings);
|
||||
await ClipVectorDB.instance.bulkInsertEmbeddings<int>(
|
||||
fileIDs: fileIDs, embeddings: embeddings);
|
||||
_logger.info("Inserted ${fileIDs.length} embeddings to ClipVectorDB");
|
||||
processedCount += fileIDs.length;
|
||||
offset += batchSize;
|
||||
@@ -1396,7 +1397,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> putClip(List<ClipEmbedding> embeddings) async {
|
||||
Future<void> putClip<int>(List<ClipEmbedding<int>> embeddings) async {
|
||||
if (embeddings.isEmpty) return;
|
||||
final db = await instance.asyncDB;
|
||||
if (embeddings.length == 1) {
|
||||
@@ -1406,8 +1407,9 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
);
|
||||
if (flagService.enableVectorDb &&
|
||||
await ClipVectorDB.instance.checkIfMigrationDone()) {
|
||||
final e = embeddings.first.fileID;
|
||||
await ClipVectorDB.instance.insertEmbedding(
|
||||
fileID: embeddings.first.fileID,
|
||||
fileID: e,
|
||||
embedding: embeddings.first.embedding,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import "package:photos/models/ml/face/face.dart";
|
||||
import "package:photos/models/ml/face/face_with_embedding.dart";
|
||||
import "package:photos/models/ml/ml_versions.dart";
|
||||
|
||||
Map<String, dynamic> mapRemoteToFaceDB(Face face) {
|
||||
Map<String, dynamic> mapRemoteToFaceDB<T>(Face<T> face) {
|
||||
return {
|
||||
faceIDColumn: face.faceID,
|
||||
fileIDColumn: face.fileID,
|
||||
@@ -24,10 +24,10 @@ Map<String, dynamic> mapRemoteToFaceDB(Face face) {
|
||||
};
|
||||
}
|
||||
|
||||
Face mapRowToFace(Map<String, dynamic> row) {
|
||||
Face mapRowToFace<T>(Map<String, dynamic> row) {
|
||||
return Face(
|
||||
row[faceIDColumn] as String,
|
||||
row[fileIDColumn] as int,
|
||||
row[fileIDColumn] as T,
|
||||
EVector.fromBuffer(row[embeddingColumn] as List<int>).values,
|
||||
row[faceScore] as double,
|
||||
Detection.fromJson(json.decode(row[faceDetectionColumn] as String)),
|
||||
@@ -39,10 +39,12 @@ Face mapRowToFace(Map<String, dynamic> row) {
|
||||
);
|
||||
}
|
||||
|
||||
FaceWithoutEmbedding mapRowToFaceWithoutEmbedding(Map<String, dynamic> row) {
|
||||
return FaceWithoutEmbedding(
|
||||
FaceWithoutEmbedding<T> mapRowToFaceWithoutEmbedding<T>(
|
||||
Map<String, dynamic> row,
|
||||
) {
|
||||
return FaceWithoutEmbedding<T>(
|
||||
row[faceIDColumn] as String,
|
||||
row[fileIDColumn] as int,
|
||||
row[fileIDColumn] as T,
|
||||
row[faceScore] as double,
|
||||
Detection.fromJson(json.decode(row[faceDetectionColumn] as String)),
|
||||
row[faceBlur] as double,
|
||||
|
||||
1266
mobile/apps/photos/lib/db/ml/offlinedb.dart
Normal file
@@ -17,8 +17,9 @@ const personIdColumn = 'person_id';
|
||||
const clusterIDColumn = 'cluster_id';
|
||||
const personOrClusterIdColumn = 'person_or_cluster_id';
|
||||
|
||||
const createFacesTable = '''CREATE TABLE IF NOT EXISTS $facesTable (
|
||||
$fileIDColumn INTEGER NOT NULL,
|
||||
String getCreateFacesTable(bool isOfflineDB) {
|
||||
return '''CREATE TABLE IF NOT EXISTS $facesTable (
|
||||
$fileIDColumn ${isOfflineDB ? 'TEXT' : 'INTEGER'} NOT NULL,
|
||||
$faceIDColumn TEXT NOT NULL UNIQUE,
|
||||
$faceDetectionColumn TEXT NOT NULL,
|
||||
$embeddingColumn BLOB NOT NULL,
|
||||
@@ -31,6 +32,7 @@ const createFacesTable = '''CREATE TABLE IF NOT EXISTS $facesTable (
|
||||
PRIMARY KEY($fileIDColumn, $faceIDColumn)
|
||||
);
|
||||
''';
|
||||
}
|
||||
|
||||
const deleteFacesTable = 'DELETE FROM $facesTable';
|
||||
// End of Faces Table Fields & Schema Queries
|
||||
@@ -98,18 +100,20 @@ const deleteNotPersonFeedbackTable = 'DELETE FROM $notPersonFeedback';
|
||||
// ## CLIP EMBEDDINGS TABLE
|
||||
const clipTable = 'clip';
|
||||
|
||||
const createClipEmbeddingsTable = '''
|
||||
CREATE TABLE IF NOT EXISTS $clipTable (
|
||||
$fileIDColumn INTEGER NOT NULL,
|
||||
String getCreateClipEmbeddingsTable(bool isOfflineDB) {
|
||||
return '''CREATE TABLE IF NOT EXISTS $clipTable (
|
||||
$fileIDColumn ${isOfflineDB ? 'TEXT' : 'INTEGER'} NOT NULL,
|
||||
$embeddingColumn BLOB NOT NULL,
|
||||
$mlVersionColumn INTEGER NOT NULL,
|
||||
PRIMARY KEY ($fileIDColumn)
|
||||
PRIMARY KEY($fileIDColumn)
|
||||
);
|
||||
''';
|
||||
''';
|
||||
}
|
||||
|
||||
const deleteClipEmbeddingsTable = 'DELETE FROM $clipTable';
|
||||
|
||||
const fileDataTable = 'filedata';
|
||||
|
||||
const createFileDataTable = '''
|
||||
CREATE TABLE IF NOT EXISTS $fileDataTable (
|
||||
$fileIDColumn INTEGER NOT NULL,
|
||||
|
||||
193
mobile/apps/photos/lib/db/remote/db.dart
Normal file
@@ -0,0 +1,193 @@
|
||||
import "dart:io";
|
||||
|
||||
import "package:collection/collection.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:path/path.dart";
|
||||
import "package:path_provider/path_provider.dart";
|
||||
import "package:photos/db/common/base.dart";
|
||||
import "package:photos/db/remote/mappers.dart";
|
||||
import "package:photos/db/remote/schema.dart";
|
||||
import "package:photos/log/devlog.dart";
|
||||
import "package:photos/models/api/diff/diff.dart";
|
||||
import "package:photos/models/collection/collection.dart";
|
||||
import "package:photos/models/file/remote/asset.dart";
|
||||
import "package:sqlite_async/sqlite_async.dart";
|
||||
|
||||
// ignore: constant_identifier_names
|
||||
enum RemoteTable { collections, collection_files, files, entities, trash }
|
||||
|
||||
class RemoteDB with SqlDbBase {
|
||||
static const _databaseName = "remotex6.db";
|
||||
static const _batchInsertMaxCount = 1000;
|
||||
late final SqliteDatabase _sqliteDB;
|
||||
|
||||
Future<void> init() async {
|
||||
devLog("Starting RemoteDB init");
|
||||
final Directory documentsDirectory =
|
||||
await getApplicationDocumentsDirectory();
|
||||
final String path = join(documentsDirectory.path, _databaseName);
|
||||
|
||||
final db = SqliteDatabase(path: path);
|
||||
await migrate(db, RemoteDBMigration.migrationScripts, onForeignKey: true);
|
||||
_sqliteDB = db;
|
||||
debugPrint("RemoteDB init complete $path");
|
||||
}
|
||||
|
||||
SqliteDatabase get sqliteDB => _sqliteDB;
|
||||
|
||||
Future<List<Collection>> getAllCollections() async {
|
||||
final result = <Collection>[];
|
||||
final cursor = await _sqliteDB.getAll("SELECT * FROM collections");
|
||||
for (final row in cursor) {
|
||||
result.add(Collection.fromRow(row));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> clearAllTables() async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.wait([
|
||||
_sqliteDB.execute('DELETE FROM collections'),
|
||||
_sqliteDB.execute('DELETE FROM collection_files'),
|
||||
_sqliteDB.execute('DELETE FROM files'),
|
||||
_sqliteDB.execute('DELETE FROM files_metadata'),
|
||||
_sqliteDB.execute('DELETE FROM trash'),
|
||||
_sqliteDB.execute('DELETE FROM upload_mapping'),
|
||||
]);
|
||||
debugPrint(
|
||||
'$runtimeType clearAllTables complete in ${stopwatch.elapsed.inMilliseconds}ms',
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<int, int>> getCollectionIDToUpdationTime() async {
|
||||
final result = <int, int>{};
|
||||
final cursor = await _sqliteDB.getAll(
|
||||
"SELECT id, updation_time FROM collections where is_deleted = 0",
|
||||
);
|
||||
for (final row in cursor) {
|
||||
result[row['id'] as int] = row['updation_time'] as int;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<RemoteAsset>> getRemoteAssets() async {
|
||||
final result = <RemoteAsset>[];
|
||||
final cursor = await _sqliteDB.getAll(
|
||||
"SELECT * FROM files",
|
||||
);
|
||||
for (final row in cursor) {
|
||||
result.add(fromFilesRow(row));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> insertCollections(List<Collection> collections) async {
|
||||
if (collections.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.forEach(collections.slices(_batchInsertMaxCount),
|
||||
(slice) async {
|
||||
final List<List<Object?>> values =
|
||||
slice.map((e) => e.rowValiues()).toList();
|
||||
await _sqliteDB.executeBatch(
|
||||
'INSERT INTO collections ($collectionColumns) values($collectionValuePlaceHolder) ON CONFLICT(id) DO UPDATE SET $updateCollectionColumns',
|
||||
values,
|
||||
);
|
||||
});
|
||||
debugPrint(
|
||||
'$runtimeType insertCollections complete in ${stopwatch.elapsed.inMilliseconds}ms for ${collections.length} collections',
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<RemoteAsset>> insertDiffItems(
|
||||
List<DiffItem> items,
|
||||
) async {
|
||||
if (items.isEmpty) return [];
|
||||
final List<RemoteAsset> assets = [];
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.forEach(items.slices(_batchInsertMaxCount), (slice) async {
|
||||
final List<List<Object?>> collectionFileValues = [];
|
||||
final List<List<Object?>> fileValues = [];
|
||||
final List<List<Object?>> fileMetadataValues = [];
|
||||
for (final item in slice) {
|
||||
final rAsset = item.fileItem.toRemoteAsset();
|
||||
collectionFileValues.add(item.collectionFileRowValues());
|
||||
fileMetadataValues.add(item.fileItem.filesMetadataRowValues());
|
||||
fileValues.add(remoteAssetToRow(rAsset));
|
||||
assets.add(rAsset);
|
||||
}
|
||||
await Future.wait([
|
||||
_sqliteDB.executeBatch(
|
||||
'INSERT INTO collection_files ($collectionFilesColumns) values(?, ?, ?, ?, ?, ?) ON CONFLICT(file_id, collection_id) DO UPDATE SET $collectionFilesUpdateColumns',
|
||||
collectionFileValues,
|
||||
),
|
||||
_sqliteDB.executeBatch(
|
||||
'INSERT INTO files ($filesColumns) values(${getParams(23)}) ON CONFLICT(id) DO UPDATE SET $filesUpdateColumns',
|
||||
fileValues,
|
||||
),
|
||||
_sqliteDB.executeBatch(
|
||||
'INSERT INTO files_metadata ($filesMetadataColumns) values(${getParams(5)}) ON CONFLICT(id) DO UPDATE SET $filesMetadataUpdateColumns',
|
||||
fileMetadataValues,
|
||||
),
|
||||
]);
|
||||
});
|
||||
debugPrint(
|
||||
'$runtimeType insertCollectionFilesDiff complete in ${stopwatch.elapsed.inMilliseconds}ms for ${items.length}',
|
||||
);
|
||||
return assets;
|
||||
}
|
||||
|
||||
Future<void> deleteFilesDiff(
|
||||
List<DiffItem> items,
|
||||
) async {
|
||||
final int collectionID = items.first.collectionID;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.forEach(items.slices(_batchInsertMaxCount), (slice) async {
|
||||
await _sqliteDB.execute(
|
||||
'DELETE FROM collection_files WHERE file_id IN (${slice.map((e) => e.fileID).join(',')}) AND collection_id = $collectionID',
|
||||
);
|
||||
});
|
||||
debugPrint(
|
||||
'$runtimeType deleteCollectionFilesDiff complete in ${stopwatch.elapsed.inMilliseconds}ms for ${items.length}',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteEntries<T>(Set<T> ids, RemoteTable table) async {
|
||||
if (ids.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await _sqliteDB.execute(
|
||||
'DELETE FROM ${table.name.toLowerCase()} WHERE id IN (${ids.join(',')})',
|
||||
);
|
||||
debugPrint(
|
||||
'$runtimeType deleteEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${ids.length} $table entries',
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> rowCount(
|
||||
RemoteTable table,
|
||||
) async {
|
||||
final row = await _sqliteDB.get(
|
||||
'SELECT COUNT(*) as count FROM ${table.name}',
|
||||
);
|
||||
return row['count'] as int;
|
||||
}
|
||||
|
||||
Future<Set<T>> _getByIds<T>(
|
||||
Set<int> ids,
|
||||
String table,
|
||||
T Function(
|
||||
Map<String, Object?> row,
|
||||
) mapRow, {
|
||||
String columnName = "id",
|
||||
}) async {
|
||||
final result = <T>{};
|
||||
if (ids.isNotEmpty) {
|
||||
final rows = await _sqliteDB.getAll(
|
||||
'SELECT * from $table where $columnName IN (${ids.join(',')})',
|
||||
);
|
||||
for (final row in rows) {
|
||||
result.add(mapRow(row));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
114
mobile/apps/photos/lib/db/remote/mappers.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:photos/models/api/diff/diff.dart";
|
||||
import "package:photos/models/api/diff/trash_time.dart";
|
||||
import "package:photos/models/file/file.dart";
|
||||
import "package:photos/models/file/remote/asset.dart";
|
||||
import "package:photos/models/file/remote/collection_file.dart";
|
||||
import "package:photos/models/file/remote/rl_mapping.dart";
|
||||
import "package:photos/models/location/location.dart";
|
||||
|
||||
RemoteAsset fromTrashRow(Map<String, dynamic> row) {
|
||||
final metadata = Metadata.fromEncodedJson(row['metadata']);
|
||||
final privateMetadata = Metadata.fromEncodedJson(row['priv_metadata']);
|
||||
final publicMetadata = Metadata.fromEncodedJson(row['pub_metadata']);
|
||||
final info = Info.fromEncodedJson(row['info']);
|
||||
return RemoteAsset.fromMetadata(
|
||||
id: row['id'],
|
||||
ownerID: row['owner_id'],
|
||||
thumbHeader: row['thumb_header'],
|
||||
fileHeader: row['file_header'],
|
||||
metadata: metadata!,
|
||||
privateMetadata: privateMetadata,
|
||||
publicMetadata: publicMetadata,
|
||||
info: info,
|
||||
);
|
||||
}
|
||||
|
||||
List<Object?> remoteAssetToRow(RemoteAsset asset) {
|
||||
return [
|
||||
asset.id,
|
||||
asset.ownerID,
|
||||
asset.fileHeader,
|
||||
asset.thumbHeader,
|
||||
asset.creationTime,
|
||||
asset.modificationTime,
|
||||
asset.type,
|
||||
asset.subType,
|
||||
asset.title,
|
||||
asset.fileSize,
|
||||
asset.hash,
|
||||
asset.visibility,
|
||||
asset.durationInSec,
|
||||
asset.location?.latitude,
|
||||
asset.location?.longitude,
|
||||
asset.height,
|
||||
asset.width,
|
||||
asset.noThumb,
|
||||
asset.sv,
|
||||
asset.mediaType,
|
||||
asset.motionVideoIndex,
|
||||
asset.caption,
|
||||
asset.uploaderName,
|
||||
];
|
||||
}
|
||||
|
||||
RemoteAsset fromFilesRow(Map<String, Object?> row) {
|
||||
return RemoteAsset(
|
||||
id: row['id'] as int,
|
||||
ownerID: row['owner_id'] as int,
|
||||
thumbHeader: row['thumb_header'] as Uint8List,
|
||||
fileHeader: row['file_header'] as Uint8List,
|
||||
creationTime: row['creation_time'] as int,
|
||||
modificationTime: row['modification_time'] as int,
|
||||
type: row['type'] as int,
|
||||
subType: row['subtype'] as int,
|
||||
title: row['title'] as String,
|
||||
fileSize: row['size'] as int?,
|
||||
hash: row['hash'] as String?,
|
||||
visibility: row['visibility'] as int?,
|
||||
durationInSec: row['durationInSec'] as int?,
|
||||
location: Location(
|
||||
latitude: (row['lat'] as num?)?.toDouble(),
|
||||
longitude: (row['lng'] as num?)?.toDouble(),
|
||||
),
|
||||
height: row['height'] as int?,
|
||||
width: row['width'] as int?,
|
||||
noThumb: row['no_thumb'] as int?,
|
||||
sv: row['sv'] as int?,
|
||||
mediaType: row['media_type'] as int?,
|
||||
motionVideoIndex: row['motion_video_index'] as int?,
|
||||
caption: row['caption'] as String?,
|
||||
uploaderName: row['uploader_name'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
RLMapping rowToUploadLocalMapping(Map<String, Object?> row) {
|
||||
return RLMapping(
|
||||
remoteUploadID: row['file_id'] as int,
|
||||
localID: row['local_id'] as String,
|
||||
localCloudID: row['local_cloud_id'] as String?,
|
||||
mappingType:
|
||||
MappingTypeExtension.fromName(row['local_mapping_src'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
EnteFile trashRowToEnteFile(Map<String, Object?> row) {
|
||||
final RemoteAsset asset = fromTrashRow(row);
|
||||
final TrashTime time = TrashTime(
|
||||
createdAt: row['created_at'] as int,
|
||||
updatedAt: row['updated_at'] as int,
|
||||
deleteBy: row['delete_by'] as int,
|
||||
);
|
||||
final cf = CollectionFile(
|
||||
fileID: asset.id,
|
||||
collectionID: row['collection_id'] as int,
|
||||
encFileKey: row['enc_key'] as Uint8List,
|
||||
encFileKeyNonce: row['enc_key_nonce'] as Uint8List,
|
||||
updatedAt: time.updatedAt,
|
||||
createdAt: time.createdAt,
|
||||
);
|
||||
final file = EnteFile.fromRemoteAsset(asset, cf);
|
||||
file.trashTime = time;
|
||||
return file;
|
||||
}
|
||||
235
mobile/apps/photos/lib/db/remote/schema.dart
Normal file
@@ -0,0 +1,235 @@
|
||||
const collectionColumns =
|
||||
'id, owner, enc_key, enc_key_nonce, name, type, local_path, is_deleted, '
|
||||
'updation_time, sharees, public_urls, mmd_encoded_json, '
|
||||
'mmd_ver, pub_mmd_encoded_json, pub_mmd_ver, shared_mmd_json, '
|
||||
'shared_mmd_ver';
|
||||
|
||||
final String updateCollectionColumns = collectionColumns
|
||||
.split(', ')
|
||||
.where((column) => column != 'id') // Exclude primary key from update
|
||||
.map((column) => '$column = excluded.$column') // Use excluded virtual table
|
||||
.join(', ');
|
||||
|
||||
const collectionFilesColumns =
|
||||
'collection_id, file_id, enc_key, enc_key_nonce, created_at, updated_at';
|
||||
|
||||
final String collectionFilesUpdateColumns = collectionFilesColumns
|
||||
.split(', ')
|
||||
.where(
|
||||
(column) =>
|
||||
column != 'collection_id' ||
|
||||
column != 'file_id' ||
|
||||
column != 'created_at',
|
||||
)
|
||||
.map((column) => '$column = excluded.$column') // Use excluded virtual table
|
||||
.join(', ');
|
||||
|
||||
const filesColumns =
|
||||
'id, owner_id, file_header, thumb_header, creation_time, modification_time, '
|
||||
'type, subtype, title, size, hash, visibility, durationInSec, lat, lng, '
|
||||
'height, width, no_thumb, sv, media_type, motion_video_index, caption, uploader_name';
|
||||
|
||||
final String filesUpdateColumns = filesColumns
|
||||
.split(', ')
|
||||
.where((column) => (column != 'id'))
|
||||
.map((column) => '$column = excluded.$column') // Use excluded virtual table
|
||||
.join(', ');
|
||||
|
||||
const filesMetadataColumns = 'id, metadata, priv_metadata, pub_metadata, info';
|
||||
final String filesMetadataUpdateColumns = filesMetadataColumns
|
||||
.split(', ')
|
||||
.where((column) => (column != 'id'))
|
||||
.map((column) => '$column = excluded.$column') // Use excluded virtual table
|
||||
.join(', ');
|
||||
|
||||
const trashedFilesColumns =
|
||||
'id, owner_id, collection_id, enc_key,enc_key_nonce, file_header, thumb_header, metadata, priv_metadata, pub_metadata, info, created_at, updated_at, delete_by';
|
||||
|
||||
final String trashedFilesUpdateColumns = trashedFilesColumns
|
||||
.split(', ')
|
||||
.where((column) => (column != 'id'))
|
||||
.map((column) => '$column = excluded.$column') // Use excluded virtual table
|
||||
.join(', ');
|
||||
|
||||
const uploadLocalMappingColumns =
|
||||
'file_id, local_id, local_cloud_id, local_mapping_src';
|
||||
String collectionValuePlaceHolder =
|
||||
collectionColumns.split(',').map((_) => '?').join(',');
|
||||
|
||||
class RemoteDBMigration {
|
||||
static const migrationScripts = [
|
||||
'''
|
||||
CREATE TABLE collections (
|
||||
id INTEGER PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
enc_key TEXT NOT NULL,
|
||||
enc_key_nonce TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
local_path TEXT,
|
||||
is_deleted INTEGER NOT NULL,
|
||||
updation_time INTEGER NOT NULL,
|
||||
sharees TEXT NOT NULL DEFAULT '[]',
|
||||
public_urls TEXT NOT NULL DEFAULT '[]',
|
||||
mmd_encoded_json TEXT NOT NULL DEFAULT '{}',
|
||||
mmd_ver INTEGER NOT NULL DEFAULT 0,
|
||||
pub_mmd_encoded_json TEXT DEFAULT '{}',
|
||||
pub_mmd_ver INTEGER NOT NULL DEFAULT 0,
|
||||
shared_mmd_json TEXT NOT NULL DEFAULT '{}',
|
||||
shared_mmd_ver INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE collection_files (
|
||||
file_id INTEGER NOT NULL,
|
||||
collection_id INTEGER NOT NULL,
|
||||
enc_key BLOB NOT NULL,
|
||||
enc_key_nonce BLOB NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (file_id, collection_id)
|
||||
);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
owner_id INTEGER NOT NULL,
|
||||
file_header BLOB NOT NULL,
|
||||
thumb_header BLOB NOT NULL,
|
||||
creation_time INTEGER NOT NULL,
|
||||
modification_time INTEGER NOT NULL,
|
||||
type INTEGER NOT NULL,
|
||||
subtype INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
size INTEGER,
|
||||
hash TEXT,
|
||||
visibility integer,
|
||||
durationInSec INTEGER,
|
||||
lat REAL DEFAULT NULL,
|
||||
lng REAL DEFAULT NULL,
|
||||
height INTEGER,
|
||||
width INTEGER,
|
||||
no_thumb INTEGER,
|
||||
sv INTEGER,
|
||||
media_type INTEGER,
|
||||
motion_video_index INTEGER,
|
||||
caption TEXT,
|
||||
uploader_name TEXT
|
||||
)
|
||||
''',
|
||||
'''
|
||||
CREATE INDEX IF NOT EXISTS file_hash_index ON files(hash);
|
||||
''',
|
||||
'''
|
||||
CREATE INDEX IF NOT EXISTS file_creation_time_index ON files(creation_time);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE files_metadata (
|
||||
id INTEGER PRIMARY KEY,
|
||||
metadata TEXT NOT NULL,
|
||||
priv_metadata TEXT,
|
||||
pub_metadata TEXT,
|
||||
info TEXT,
|
||||
FOREIGN KEY (id) REFERENCES files(id) ON DELETE CASCADE
|
||||
)
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE trash (
|
||||
id INTEGER PRIMARY KEY,
|
||||
owner_id INTEGER NOT NULL,
|
||||
collection_id INTEGER NOT NULL,
|
||||
enc_key BLOB NOT NULL,
|
||||
enc_key_nonce BLOB NOT NULL,
|
||||
metadata TEXT NOT NULL,
|
||||
priv_metadata TEXT,
|
||||
pub_metadata TEXT,
|
||||
info TEXT,
|
||||
file_header BLOB NOT NULL,
|
||||
thumb_header BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
delete_by INTEGER NOT NULL
|
||||
)
|
||||
''',
|
||||
'''
|
||||
CREATE TRIGGER delete_orphaned_files
|
||||
AFTER DELETE ON collection_files
|
||||
FOR EACH ROW
|
||||
WHEN (
|
||||
-- Only proceed if this file_id actually existed before deletion
|
||||
OLD.file_id IS NOT NULL
|
||||
-- And only if this was the last reference to the file
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM collection_files
|
||||
WHERE file_id = OLD.file_id
|
||||
)
|
||||
)
|
||||
BEGIN
|
||||
-- Only then delete from files table
|
||||
DELETE FROM files WHERE id = OLD.file_id;
|
||||
END;
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE upload_mapping (
|
||||
file_id INTEGER PRIMARY KEY,
|
||||
local_id TEXT NOT NULL,
|
||||
-- icloud identifier if available
|
||||
local_cloud_id TEXT,
|
||||
local_mapping_src TEXT DEFAULT NULL,
|
||||
FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE
|
||||
)'''
|
||||
];
|
||||
}
|
||||
|
||||
class FilterQueryParam {
|
||||
int? collectionID;
|
||||
int? limit;
|
||||
int? offset;
|
||||
String? orderByColumn;
|
||||
bool isAsc;
|
||||
(int?, int?)? createAtRange;
|
||||
|
||||
FilterQueryParam({
|
||||
this.limit,
|
||||
this.offset,
|
||||
this.collectionID,
|
||||
this.orderByColumn = "creation_time",
|
||||
this.isAsc = false,
|
||||
this.createAtRange,
|
||||
});
|
||||
|
||||
String get orderBy => orderByColumn == null
|
||||
? ""
|
||||
: "ORDER BY $orderByColumn ${isAsc ? "ASC" : "DESC"}";
|
||||
|
||||
String get limitOffset => (limit != null && offset != null)
|
||||
? "LIMIT $limit + OFFSET $offset)"
|
||||
: (limit != null)
|
||||
? "LIMIT $limit"
|
||||
: "";
|
||||
|
||||
String get collectionFilter =>
|
||||
(collectionID == null) ? "" : "collection_id = $collectionID";
|
||||
|
||||
String get createAtRangeStr => (createAtRange == null ||
|
||||
createAtRange!.$1 == null)
|
||||
? ""
|
||||
: "(creation_time BETWEEN ${createAtRange!.$1} AND ${createAtRange!.$2})";
|
||||
|
||||
String whereClause() {
|
||||
final where = <String>[];
|
||||
if (collectionFilter.isNotEmpty) {
|
||||
where.add(collectionFilter);
|
||||
}
|
||||
if (createAtRangeStr.isNotEmpty) {
|
||||
where.add(createAtRangeStr);
|
||||
}
|
||||
|
||||
return (where.isEmpty ? "" : where.join(" AND ")) +
|
||||
" " +
|
||||
orderBy +
|
||||
" " +
|
||||
limitOffset;
|
||||
}
|
||||
}
|
||||
288
mobile/apps/photos/lib/db/remote/table/collection_files.dart
Normal file
@@ -0,0 +1,288 @@
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:photos/db/remote/db.dart";
|
||||
import "package:photos/db/remote/schema.dart";
|
||||
import "package:photos/extensions/stop_watch.dart";
|
||||
import "package:photos/models/file/remote/collection_file.dart";
|
||||
|
||||
extension CollectionFiles on RemoteDB {
|
||||
Future<int> getCollectionFileCount(int collectionID) async {
|
||||
final row = await sqliteDB.get(
|
||||
"SELECT COUNT(*) as count FROM collection_files WHERE collection_id = ?",
|
||||
[collectionID],
|
||||
);
|
||||
return row["count"] as int;
|
||||
}
|
||||
|
||||
Future<Set<int>> getUploadedFileIDs(int collectionID) async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT file_id FROM collection_files WHERE collection_id = ?",
|
||||
[collectionID],
|
||||
);
|
||||
final Set<int> fileIDs = {};
|
||||
for (var row in rows) {
|
||||
fileIDs.add(row["file_id"] as int);
|
||||
}
|
||||
return fileIDs;
|
||||
}
|
||||
|
||||
Future<Set<int>> getAllCollectionIDsOfFile(int fileID) async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_id FROM collection_files WHERE file_id = ?",
|
||||
[fileID],
|
||||
);
|
||||
final Set<int> collectionIDs = {};
|
||||
for (var row in rows) {
|
||||
collectionIDs.add(row["collection_id"] as int);
|
||||
}
|
||||
return collectionIDs;
|
||||
}
|
||||
|
||||
Future<Map<int, List<CollectionFile>>> getCollectionFilesGroupedByCollection(
|
||||
List<int> fileIDs,
|
||||
) async {
|
||||
final result = <int, List<CollectionFile>>{};
|
||||
if (fileIDs.isEmpty) {
|
||||
return result;
|
||||
}
|
||||
final inParam = fileIDs.map((id) => "'$id'").join(',');
|
||||
final results = await sqliteDB.getAll(
|
||||
'SELECT * FROM collection_files WHERE file_id IN ($inParam)',
|
||||
);
|
||||
for (final row in results) {
|
||||
final eachFile = CollectionFile.fromMap(row);
|
||||
if (!result.containsKey(eachFile.collectionID)) {
|
||||
result[eachFile.collectionID] = <CollectionFile>[];
|
||||
}
|
||||
result[eachFile.collectionID]!.add(eachFile);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<CollectionFile>> getAllCFForFileIDs(
|
||||
List<int> fileIDs,
|
||||
) async {
|
||||
if (fileIDs.isEmpty) return [];
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT * FROM collection_files WHERE file_id IN (${fileIDs.join(",")})",
|
||||
);
|
||||
return rows
|
||||
.map((row) => CollectionFile.fromMap(row))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<Map<int, int>> getCollectionIdToFileCount(List<int> fileIDs) async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_id, COUNT(*) as count FROM collection_files WHERE file_id IN (${fileIDs.join(",")}) GROUP BY collection_id",
|
||||
);
|
||||
final Map<int, int> collectionIdToFileCount = {};
|
||||
for (var row in rows) {
|
||||
final collectionId = row["collection_id"] as int;
|
||||
final count = row["count"] as int;
|
||||
collectionIdToFileCount[collectionId] = count;
|
||||
}
|
||||
return collectionIdToFileCount;
|
||||
}
|
||||
|
||||
Future<List<CollectionFile>> getCollectionFiles(
|
||||
FilterQueryParam? params,
|
||||
) async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_files.* FROM collection_files JOIN files on collection_files.file_id=files.id WHERE ${params?.whereClause() ?? "order by creation_time desc"}",
|
||||
);
|
||||
return rows
|
||||
.map((row) => CollectionFile.fromMap(row))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<List<CollectionFile>> getCollectionsFiles(
|
||||
Set<int> collectionIDs,
|
||||
) async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_files.* FROM collection_files JOIN files on collection_files.file_id=files.id WHERE collection_id IN (${collectionIDs.join(",")}) ORDER BY creation_time DESC",
|
||||
);
|
||||
return rows
|
||||
.map((row) => CollectionFile.fromMap(row))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<Map<int, CollectionFile>> getFileIdToCollectionFile(
|
||||
List<int> fileIDs,
|
||||
) async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_files.* FROM collection_files JOIN files on collection_files.file_id=files.id WHERE file_id IN (${fileIDs.join(",")})",
|
||||
);
|
||||
final Map<int, CollectionFile> result = {};
|
||||
for (var row in rows) {
|
||||
final entry = CollectionFile.fromMap(row);
|
||||
result[entry.fileID] = entry;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<CollectionFile>> getAllFiles(int userID) {
|
||||
return sqliteDB.getAll(
|
||||
"SELECT collection_files.* FROM collection_files JOIN files ON collection_files.file_id = files.id WHERE files.owner_id = ? ORDER BY files.creation_time DESC",
|
||||
[userID],
|
||||
).then(
|
||||
(rows) => rows.map((row) => CollectionFile.fromMap(row)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<(Set<int>, Map<String, int>)> getUploadAndHash(
|
||||
int collectionID,
|
||||
) async {
|
||||
final results = await sqliteDB.getAll(
|
||||
'SELECT id, hash FROM collection_files JOIN files ON files.id = collection_files.file_id'
|
||||
' WHERE collection_id = ?',
|
||||
[
|
||||
collectionID,
|
||||
],
|
||||
);
|
||||
final ids = <int>{};
|
||||
final hash = <String, int>{};
|
||||
for (final result in results) {
|
||||
ids.add(result['id'] as int);
|
||||
if (result['hash'] != null) {
|
||||
hash[result['hash'] as String] = result['id'] as int;
|
||||
}
|
||||
}
|
||||
return (ids, hash);
|
||||
}
|
||||
|
||||
Future<List<CollectionFile>> ownedFilesWithSameHash(
|
||||
List<String> hashes,
|
||||
int ownerID,
|
||||
) async {
|
||||
if (hashes.isEmpty) return [];
|
||||
final inParam = hashes.map((e) => "'$e'").join(',');
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_files.* FROM collection_files JOIN files ON collection_files.file_id = files.id WHERE files.hash IN ($inParam) AND files.owner_id = ?",
|
||||
[ownerID],
|
||||
);
|
||||
return rows
|
||||
.map((row) => CollectionFile.fromMap(row))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<CollectionFile?> coverFile(
|
||||
int collectionID,
|
||||
int? fileID, {
|
||||
bool sortInAsc = false,
|
||||
}) async {
|
||||
if (fileID != null) {
|
||||
final entry = await getCollectionFileEntry(collectionID, fileID);
|
||||
if (entry != null) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
final sortedRow = await sqliteDB.getOptional(
|
||||
"SELECT collection_files.* FROM collection_files join files on files.id= collection_files.file_id WHERE collection_id = ? ORDER BY files.creation_time ${sortInAsc ? 'ASC' : 'DESC'} LIMIT 1",
|
||||
[collectionID],
|
||||
);
|
||||
if (sortedRow != null) {
|
||||
return CollectionFile.fromMap(sortedRow);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<CollectionFile?> getCollectionFileEntry(
|
||||
int collectionID,
|
||||
int fileID,
|
||||
) async {
|
||||
final row = await sqliteDB.getOptional(
|
||||
"SELECT * FROM collection_files WHERE collection_id = ? AND file_id = ?",
|
||||
[collectionID, fileID],
|
||||
);
|
||||
if (row != null) {
|
||||
return CollectionFile.fromMap(row);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<CollectionFile?> getAnyCollectionEntry(
|
||||
int fileID,
|
||||
) async {
|
||||
final row = await sqliteDB.getAll(
|
||||
"SELECT * FROM collection_files WHERE file_id = ? limit 1",
|
||||
[fileID],
|
||||
);
|
||||
if (row.isNotEmpty) {
|
||||
return CollectionFile.fromMap(row.first);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<List<CollectionFile>> getFilesCreatedWithinDurations(
|
||||
List<List<int>> durations,
|
||||
Set<int> ignoredCollectionIDs, {
|
||||
String order = 'DESC',
|
||||
}) async {
|
||||
final List<CollectionFile> result = [];
|
||||
for (final duration in durations) {
|
||||
final start = duration[0];
|
||||
final end = duration[1];
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_files.* FROM collection_files join files on files.id=collection_files.file_id WHERE files.creation_time BETWEEN ? AND ? AND collection_id NOT IN (${ignoredCollectionIDs.join(",")}) ORDER BY creation_time $order",
|
||||
[start, end],
|
||||
);
|
||||
result.addAll(rows.map((row) => CollectionFile.fromMap(row)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<CollectionFile>> filesWithLocation() {
|
||||
return sqliteDB
|
||||
.getAll(
|
||||
"SELECT collection_files.* FROM collection_files JOIN files ON collection_files.file_id = files.id WHERE files.lat IS NOT NULL and files.lng IS NOT NULL order by files.creation_time desc",
|
||||
)
|
||||
.then(
|
||||
(rows) => rows.map((row) => CollectionFile.fromMap(row)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteFiles(List<int> fileIDs) async {
|
||||
if (fileIDs.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await sqliteDB.execute(
|
||||
"DELETE FROM collection_files WHERE file_id IN (${fileIDs.join(",")})",
|
||||
);
|
||||
debugPrint(
|
||||
'$runtimeType deleteFiles complete in ${stopwatch.elapsed.inMilliseconds}ms for ${fileIDs.length}',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteCollectionFiles(List<int> cIDs) async {
|
||||
if (cIDs.isEmpty) return;
|
||||
await sqliteDB.execute(
|
||||
"DELETE FROM collection_files WHERE collection_id IN (${cIDs.join(",")})",
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteCFEnteries(
|
||||
int collectionID,
|
||||
List<int> fileIDs,
|
||||
) async {
|
||||
if (fileIDs.isEmpty) return;
|
||||
await sqliteDB.execute(
|
||||
"DELETE FROM collection_files WHERE collection_id = ? AND file_id IN (${fileIDs.join(",")})",
|
||||
[collectionID],
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<int, int>> getCollectionIDToMaxCreationTime() async {
|
||||
final enteWatch = EnteWatch("getCollectionIDToMaxCreationTime")..start();
|
||||
final rows = await sqliteDB.getAll(
|
||||
'''SELECT collection_id, MAX(creation_time) as max_creation_time FROM collection_files join files on
|
||||
collection_files.file_id=files.id GROUP BY collection_id''',
|
||||
);
|
||||
final Map<int, int> result = {};
|
||||
for (var row in rows) {
|
||||
final collectionId = row["collection_id"] as int;
|
||||
final maxCreationTime = row["max_creation_time"] as int;
|
||||
result[collectionId] = maxCreationTime;
|
||||
}
|
||||
enteWatch.log("query done");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
156
mobile/apps/photos/lib/db/remote/table/files_table.dart
Normal file
@@ -0,0 +1,156 @@
|
||||
import "package:photos/db/remote/db.dart";
|
||||
import "package:photos/models/api/diff/diff.dart";
|
||||
import "package:photos/models/file/file_type.dart";
|
||||
|
||||
extension FilesTable on RemoteDB {
|
||||
// For a given userID, return unique uploadedFileId for the given userID
|
||||
Future<List<int>> fileIDsWithMissingSize(int userId) async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT id FROM files WHERE owner_id = ? AND size = -1",
|
||||
[userId],
|
||||
);
|
||||
final result = <int>[];
|
||||
for (final row in rows) {
|
||||
result.add(row['id'] as int);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<Map<int, int>> getIDToCreationTime() async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT id, creation_time FROM files",
|
||||
);
|
||||
final result = <int, int>{};
|
||||
for (final row in rows) {
|
||||
result[row['id'] as int] = row['creation_time'] as int;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<Map<int, Metadata?>> getIDToMetadata(
|
||||
Set<int> ids, {
|
||||
bool private = false,
|
||||
bool public = false,
|
||||
bool metadata = false,
|
||||
}) async {
|
||||
if (ids.isEmpty) return {};
|
||||
|
||||
// Ensure only one parameter is true
|
||||
final trueCount = [private, public, metadata].where((x) => x).length;
|
||||
if (trueCount != 1) {
|
||||
throw ArgumentError(
|
||||
'Exactly one of private, public, or metadata must be true',
|
||||
);
|
||||
}
|
||||
|
||||
final placeholders = List.filled(ids.length, '?').join(',');
|
||||
String column;
|
||||
|
||||
if (private) {
|
||||
column = 'priv_metadata';
|
||||
} else if (public) {
|
||||
column = 'pub_metadata';
|
||||
} else {
|
||||
column = 'metadata';
|
||||
}
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT id, $column FROM files_metadata WHERE id IN ($placeholders)",
|
||||
ids.toList(),
|
||||
);
|
||||
final result = <int, Metadata?>{};
|
||||
for (final row in rows) {
|
||||
final metadata = Metadata.fromEncodedJson(row[column]);
|
||||
result[row['id'] as int] = metadata;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<Set<int>> idsWithSameHashAndType(String hash, int ownerID) {
|
||||
return sqliteDB.getAll(
|
||||
"SELECT id FROM files WHERE hash = ? AND owner_id = ?",
|
||||
[hash, ownerID],
|
||||
).then((rows) {
|
||||
final result = <int>{};
|
||||
for (final row in rows) {
|
||||
result.add(row['id'] as int);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
// updateSizeForUploadIDs takes a map of upploadedFileID and fileSize and
|
||||
// update the fileSize for the given uploadedFileID
|
||||
Future<void> updateSize(
|
||||
Map<int, int> idToSize,
|
||||
) async {
|
||||
final parameterSets = <List<Object?>>[];
|
||||
for (final id in idToSize.keys) {
|
||||
parameterSets.add([idToSize[id], id]);
|
||||
}
|
||||
return sqliteDB.executeBatch(
|
||||
"UPDATE files SET size = ? WHERE id = ?;",
|
||||
parameterSets,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<int>> getAllFilesAfterDate({
|
||||
required FileType fileType,
|
||||
required DateTime beginDate,
|
||||
required int userID,
|
||||
}) async {
|
||||
final results = await sqliteDB.getAll(
|
||||
'''
|
||||
SELECT files.id FROM files join upload_mapping
|
||||
ON files.id = upload_mapping.file_id
|
||||
WHERE file_type = ?
|
||||
AND creation_time > ?
|
||||
AND owner_id = ?
|
||||
AND (size IS NOT NULL AND size <= 524288000)
|
||||
AND (durationInSec IS NOT NULL AND (durationInSec <= 60 AND durationInSec > 0))
|
||||
''',
|
||||
[getInt(fileType), beginDate.microsecondsSinceEpoch, userID],
|
||||
);
|
||||
final fileIDs = <int>[];
|
||||
for (final row in results) {
|
||||
fileIDs.add(row['id'] as int);
|
||||
}
|
||||
return fileIDs;
|
||||
}
|
||||
|
||||
Future<Map<int, List<(int, Metadata?)>>> getNotificationCandidate(
|
||||
List<int> collectionIDs,
|
||||
int lastAppOpen,
|
||||
) async {
|
||||
if (collectionIDs.isEmpty) return {};
|
||||
final placeholders = List.filled(collectionIDs.length, '?').join(',');
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_id, files.owner_id, metadata FROM collection_files join files ON collection_files.file_id = files.id WHERE collection_id IN ($placeholders) AND collection_files.created_at > ?",
|
||||
[...collectionIDs, lastAppOpen],
|
||||
);
|
||||
final result = <int, List<(int, Metadata?)>>{};
|
||||
for (final row in rows) {
|
||||
final collectionID = row['collection_id'] as int;
|
||||
final ownerID = row['owner_id'] as int;
|
||||
final metadata = Metadata.fromEncodedJson(row['metadata']);
|
||||
result.putIfAbsent(collectionID, () => []).add((ownerID, metadata));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<int> getFilesCountByVisibility(
|
||||
int visibility,
|
||||
int ownerID,
|
||||
Set<int> hiddenCollections,
|
||||
) async {
|
||||
String subQuery = '';
|
||||
if (hiddenCollections.isNotEmpty) {
|
||||
subQuery =
|
||||
'AND id NOT IN (SELECT file_id FROM collection_files WHERE collection_id IN (${hiddenCollections.join(',')}))';
|
||||
}
|
||||
final row = await sqliteDB.get(
|
||||
'SELECT COUNT(id) as count FROM files WHERE visibility = ? AND owner_id = ? $subQuery',
|
||||
[visibility, ownerID],
|
||||
);
|
||||
return row['count'] as int;
|
||||
}
|
||||
}
|
||||
119
mobile/apps/photos/lib/db/remote/table/mapping_table.dart
Normal file
@@ -0,0 +1,119 @@
|
||||
import "package:collection/collection.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:photos/db/remote/db.dart";
|
||||
import "package:photos/db/remote/mappers.dart";
|
||||
import "package:photos/db/remote/schema.dart";
|
||||
import "package:photos/models/backup_status.dart";
|
||||
import "package:photos/models/file/remote/rl_mapping.dart";
|
||||
|
||||
extension UploadMappingTable on RemoteDB {
|
||||
Future<void> insertMappings(List<RLMapping> mappings) async {
|
||||
if (mappings.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.forEach(mappings.slices(1000), (slice) async {
|
||||
final List<List<Object?>> values = slice.map((e) => e.rowValues).toList();
|
||||
await sqliteDB.executeBatch(
|
||||
'INSERT INTO upload_mapping ($uploadLocalMappingColumns) values(?,?,?,?)',
|
||||
values,
|
||||
);
|
||||
});
|
||||
debugPrint(
|
||||
'$runtimeType insertMappings complete in ${stopwatch.elapsed.inMilliseconds}ms for ${mappings.length} mappings',
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<RLMapping>> getMappings() async {
|
||||
final result = <RLMapping>[];
|
||||
final cursor = await sqliteDB.getAll("SELECT * FROM upload_mapping");
|
||||
for (final row in cursor) {
|
||||
result.add(rowToUploadLocalMapping(row));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> deleteMappingsForLocalIDs(Set<String> localIDs) async {
|
||||
if (localIDs.isEmpty) return;
|
||||
final placeholders = List.filled(localIDs.length, '?').join(',');
|
||||
await sqliteDB.execute(
|
||||
'DELETE FROM upload_mapping WHERE local_id IN ($placeholders)',
|
||||
localIDs.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, RLMapping>> getLocalIDToMappingForActiveFiles() async {
|
||||
final result = <String, RLMapping>{};
|
||||
final cursor = await sqliteDB.getAll(
|
||||
"SELECT * FROM upload_mapping join files on upload_mapping.file_id = files.id",
|
||||
);
|
||||
for (final row in cursor) {
|
||||
final mapping = rowToUploadLocalMapping(row);
|
||||
result[mapping.localID] = mapping;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// getLocalIDsForUser returns information about the localIDs that have been
|
||||
// uploaded for the given userID. If the localIDSInGivenPath is not null,
|
||||
// it will only return the localIDs that are in the given path.
|
||||
Future<BackedUpFileIDs> getLocalIDsForUser(
|
||||
int userID,
|
||||
Set<String>? localIDSInGivenPath,
|
||||
) async {
|
||||
final results = await sqliteDB.getAll(
|
||||
'SELECT local_id, files.id, size FROM upload_mapping join files on upload_mapping.file_id = files.id WHERE owner_id = ?',
|
||||
[userID],
|
||||
);
|
||||
|
||||
final Set<String> localIDs = <String>{};
|
||||
final Set<int> uploadedIDs = <int>{};
|
||||
int localSize = 0;
|
||||
for (final result in results) {
|
||||
final String localID = result['local_id'] as String;
|
||||
if (localIDSInGivenPath != null &&
|
||||
!localIDSInGivenPath.contains(localID)) {
|
||||
continue; // Skip if not in the given path
|
||||
}
|
||||
final int? fileSize = result['size'] as int?;
|
||||
if (!localIDs.contains(localID) && fileSize != null) {
|
||||
localSize += fileSize;
|
||||
}
|
||||
localIDs.add(localID);
|
||||
uploadedIDs.add(result['id'] as int);
|
||||
}
|
||||
return BackedUpFileIDs(localIDs.toList(), uploadedIDs.toList(), localSize);
|
||||
}
|
||||
|
||||
Future<Set<String>> getLocalIDsWithMapping(List<String> localIDs) async {
|
||||
if (localIDs.isEmpty) return {};
|
||||
final placeholders = List.filled(localIDs.length, '?').join(',');
|
||||
final cursor = await sqliteDB.getAll(
|
||||
'SELECT local_id FROM upload_mapping join files on upload_mapping.file_id = files.id WHERE local_id IN ($placeholders)',
|
||||
localIDs,
|
||||
);
|
||||
return cursor.map((row) => row['local_id'] as String).toSet();
|
||||
}
|
||||
|
||||
Future<Map<int, String>> getFileIDToLocalIDMapping(List<int> fileIDs) async {
|
||||
if (fileIDs.isEmpty) return {};
|
||||
final placeholders = List.filled(fileIDs.length, '?').join(',');
|
||||
final cursor = await sqliteDB.getAll(
|
||||
'SELECT file_id, local_id FROM upload_mapping WHERE file_id IN ($placeholders)',
|
||||
fileIDs,
|
||||
);
|
||||
return Map.fromEntries(
|
||||
cursor.map(
|
||||
(row) => MapEntry(row['file_id'] as int, row['local_id'] as String),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Set<int>> getFilesWithMapping(List<int> fileIDs) async {
|
||||
if (fileIDs.isEmpty) return {};
|
||||
final placeholders = List.filled(fileIDs.length, '?').join(',');
|
||||
final cursor = await sqliteDB.getAll(
|
||||
'SELECT file_id FROM upload_mapping WHERE file_id IN ($placeholders)',
|
||||
fileIDs,
|
||||
);
|
||||
return cursor.map((row) => row['file_id'] as int).toSet();
|
||||
}
|
||||
}
|
||||
49
mobile/apps/photos/lib/db/remote/table/trash.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
import "package:collection/collection.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:photos/db/remote/db.dart";
|
||||
import "package:photos/db/remote/mappers.dart";
|
||||
import "package:photos/db/remote/schema.dart";
|
||||
import "package:photos/models/api/diff/diff.dart";
|
||||
import "package:photos/models/file/file.dart";
|
||||
|
||||
extension TrashTable on RemoteDB {
|
||||
Future<void> insertTrashDiffItems(List<DiffItem> items) async {
|
||||
if (items.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.forEach(items.slices(1000), (slice) async {
|
||||
final List<List<Object?>> trashRowValues = [];
|
||||
for (final item in slice) {
|
||||
trashRowValues.add(item.trashRowValues());
|
||||
}
|
||||
await Future.wait([
|
||||
sqliteDB.executeBatch(
|
||||
'INSERT INTO trash ($trashedFilesColumns) values(${getParams(14)})',
|
||||
trashRowValues,
|
||||
),
|
||||
]);
|
||||
});
|
||||
debugPrint(
|
||||
'$runtimeType insertCollectionFilesDiff complete in ${stopwatch.elapsed.inMilliseconds}ms for ${items.length}',
|
||||
);
|
||||
}
|
||||
|
||||
// removes the items and returns the number of items removed
|
||||
Future<int> removeTrashItems(List<int> ids) async {
|
||||
if (ids.isEmpty) return 0;
|
||||
final result = await sqliteDB.execute(
|
||||
'DELETE FROM trash WHERE id IN (${ids.join(",")})',
|
||||
);
|
||||
return result.isNotEmpty ? result.first['changes'] as int : 0;
|
||||
}
|
||||
|
||||
Future<List<EnteFile>> getTrashFiles() async {
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT * FROM trash',
|
||||
);
|
||||
return result.map((e) => trashRowToEnteFile(e)).toList();
|
||||
}
|
||||
|
||||
Future<void> clearTrash() async {
|
||||
await sqliteDB.execute('DELETE FROM trash');
|
||||
}
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:photos/models/file/trash_file.dart';
|
||||
import 'package:photos/models/file_load_result.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
// The TrashDB doesn't need to flatten and store all attributes of a file.
|
||||
// Before adding any other column, we should evaluate if we need to query on that
|
||||
// column or not while showing trashed items. Even if we miss storing any new attributes,
|
||||
// during restore, all file attributes will be fetched & stored as required.
|
||||
class TrashDB {
|
||||
static const _databaseName = "ente.trash.db";
|
||||
static const _databaseVersion = 1;
|
||||
static final Logger _logger = Logger("TrashDB");
|
||||
static const tableName = 'trash';
|
||||
|
||||
static const columnUploadedFileID = 'uploaded_file_id';
|
||||
static const columnCollectionID = 'collection_id';
|
||||
static const columnOwnerID = 'owner_id';
|
||||
static const columnTrashUpdatedAt = 't_updated_at';
|
||||
static const columnTrashDeleteBy = 't_delete_by';
|
||||
static const columnEncryptedKey = 'encrypted_key';
|
||||
static const columnKeyDecryptionNonce = 'key_decryption_nonce';
|
||||
static const columnFileDecryptionHeader = 'file_decryption_header';
|
||||
static const columnThumbnailDecryptionHeader = 'thumbnail_decryption_header';
|
||||
static const columnUpdationTime = 'updation_time';
|
||||
|
||||
static const columnCreationTime = 'creation_time';
|
||||
static const columnLocalID = 'local_id';
|
||||
|
||||
// standard file metadata, which isn't editable
|
||||
static const columnFileMetadata = 'file_metadata';
|
||||
|
||||
static const columnMMdEncodedJson = 'mmd_encoded_json';
|
||||
static const columnMMdVersion = 'mmd_ver';
|
||||
|
||||
static const columnPubMMdEncodedJson = 'pub_mmd_encoded_json';
|
||||
static const columnPubMMdVersion = 'pub_mmd_ver';
|
||||
|
||||
Future _onCreate(Database db, int version) async {
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $tableName (
|
||||
$columnUploadedFileID INTEGER PRIMARY KEY NOT NULL,
|
||||
$columnCollectionID INTEGER NOT NULL,
|
||||
$columnOwnerID INTEGER,
|
||||
$columnTrashUpdatedAt INTEGER NOT NULL,
|
||||
$columnTrashDeleteBy INTEGER NOT NULL,
|
||||
$columnEncryptedKey TEXT,
|
||||
$columnKeyDecryptionNonce TEXT,
|
||||
$columnFileDecryptionHeader TEXT,
|
||||
$columnThumbnailDecryptionHeader TEXT,
|
||||
$columnUpdationTime INTEGER,
|
||||
$columnLocalID TEXT,
|
||||
$columnCreationTime INTEGER NOT NULL,
|
||||
$columnFileMetadata TEXT DEFAULT '{}',
|
||||
$columnMMdEncodedJson TEXT DEFAULT '{}',
|
||||
$columnMMdVersion INTEGER DEFAULT 0,
|
||||
$columnPubMMdEncodedJson TEXT DEFAULT '{}',
|
||||
$columnPubMMdVersion INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS creation_time_index ON $tableName($columnCreationTime);
|
||||
CREATE INDEX IF NOT EXISTS delete_by_time_index ON $tableName($columnTrashDeleteBy);
|
||||
CREATE INDEX IF NOT EXISTS updated_at_time_index ON $tableName($columnTrashUpdatedAt);
|
||||
''',
|
||||
);
|
||||
}
|
||||
|
||||
TrashDB._privateConstructor();
|
||||
|
||||
static final TrashDB instance = TrashDB._privateConstructor();
|
||||
|
||||
// only have a single app-wide reference to the database
|
||||
static Future<Database>? _dbFuture;
|
||||
|
||||
Future<Database> get database async {
|
||||
// lazily instantiate the db the first time it is accessed
|
||||
_dbFuture ??= _initDatabase();
|
||||
return _dbFuture!;
|
||||
}
|
||||
|
||||
// this opens the database (and creates it if it doesn't exist)
|
||||
Future<Database> _initDatabase() async {
|
||||
final Directory documentsDirectory =
|
||||
await getApplicationDocumentsDirectory();
|
||||
final String path = join(documentsDirectory.path, _databaseName);
|
||||
_logger.info("DB path " + path);
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: _databaseVersion,
|
||||
onCreate: _onCreate,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clearTable() async {
|
||||
final db = await instance.database;
|
||||
await db.delete(tableName);
|
||||
}
|
||||
|
||||
Future<int> count() async {
|
||||
final db = await instance.database;
|
||||
final count = Sqflite.firstIntValue(
|
||||
await db.rawQuery('SELECT COUNT(*) FROM $tableName'),
|
||||
);
|
||||
return count ?? 0;
|
||||
}
|
||||
|
||||
Future<void> insertMultiple(List<TrashFile> trashFiles) async {
|
||||
final startTime = DateTime.now();
|
||||
final db = await instance.database;
|
||||
var batch = db.batch();
|
||||
int batchCounter = 0;
|
||||
for (TrashFile trash in trashFiles) {
|
||||
if (batchCounter == 400) {
|
||||
await batch.commit(noResult: true);
|
||||
batch = db.batch();
|
||||
batchCounter = 0;
|
||||
}
|
||||
batch.insert(
|
||||
tableName,
|
||||
_getRowForTrash(trash),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
batchCounter++;
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
final endTime = DateTime.now();
|
||||
final duration = Duration(
|
||||
microseconds:
|
||||
endTime.microsecondsSinceEpoch - startTime.microsecondsSinceEpoch,
|
||||
);
|
||||
_logger.info(
|
||||
"Batch insert of " +
|
||||
trashFiles.length.toString() +
|
||||
" took " +
|
||||
duration.inMilliseconds.toString() +
|
||||
"ms.",
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> delete(List<int> uploadedFileIDs) async {
|
||||
final db = await instance.database;
|
||||
return db.delete(
|
||||
tableName,
|
||||
where: '$columnUploadedFileID IN (${uploadedFileIDs.join(', ')})',
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> update(TrashFile file) async {
|
||||
final db = await instance.database;
|
||||
return await db.update(
|
||||
tableName,
|
||||
_getRowForTrash(file),
|
||||
where: '$columnUploadedFileID = ?',
|
||||
whereArgs: [file.uploadedFileID],
|
||||
);
|
||||
}
|
||||
|
||||
Future<FileLoadResult> getTrashedFiles(
|
||||
int startTime,
|
||||
int endTime, {
|
||||
int? limit,
|
||||
bool? asc,
|
||||
}) async {
|
||||
final db = await instance.database;
|
||||
final order = (asc ?? false ? 'ASC' : 'DESC');
|
||||
final results = await db.query(
|
||||
tableName,
|
||||
where: '$columnCreationTime >= ? AND $columnCreationTime <= ?',
|
||||
whereArgs: [startTime, endTime],
|
||||
orderBy: '$columnCreationTime ' + order,
|
||||
limit: limit,
|
||||
);
|
||||
final files = _convertToFiles(results);
|
||||
return FileLoadResult(files, files.length == limit);
|
||||
}
|
||||
|
||||
List<TrashFile> _convertToFiles(List<Map<String, dynamic>> results) {
|
||||
final List<TrashFile> trashedFiles = [];
|
||||
for (final result in results) {
|
||||
trashedFiles.add(_getTrashFromRow(result));
|
||||
}
|
||||
return trashedFiles;
|
||||
}
|
||||
|
||||
TrashFile _getTrashFromRow(Map<String, dynamic> row) {
|
||||
final trashFile = TrashFile();
|
||||
trashFile.updateAt = row[columnTrashUpdatedAt];
|
||||
trashFile.deleteBy = row[columnTrashDeleteBy];
|
||||
trashFile.uploadedFileID = row[columnUploadedFileID];
|
||||
// dirty hack to ensure that the file_downloads & cache mechanism works
|
||||
trashFile.generatedID = -1 * trashFile.uploadedFileID!;
|
||||
trashFile.ownerID = row[columnOwnerID];
|
||||
trashFile.collectionID =
|
||||
row[columnCollectionID] == -1 ? null : row[columnCollectionID];
|
||||
trashFile.encryptedKey = row[columnEncryptedKey];
|
||||
trashFile.keyDecryptionNonce = row[columnKeyDecryptionNonce];
|
||||
trashFile.fileDecryptionHeader = row[columnFileDecryptionHeader];
|
||||
trashFile.thumbnailDecryptionHeader = row[columnThumbnailDecryptionHeader];
|
||||
trashFile.updationTime = row[columnUpdationTime] ?? 0;
|
||||
trashFile.creationTime = row[columnCreationTime];
|
||||
final fileMetadata = row[columnFileMetadata] ?? '{}';
|
||||
trashFile.applyMetadata(jsonDecode(fileMetadata));
|
||||
trashFile.localID = row[columnLocalID];
|
||||
|
||||
trashFile.mMdVersion = row[columnMMdVersion] ?? 0;
|
||||
trashFile.mMdEncodedJson = row[columnMMdEncodedJson] ?? '{}';
|
||||
|
||||
trashFile.pubMmdVersion = row[columnPubMMdVersion] ?? 0;
|
||||
trashFile.pubMmdEncodedJson = row[columnPubMMdEncodedJson] ?? '{}';
|
||||
|
||||
if (trashFile.pubMagicMetadata != null &&
|
||||
trashFile.pubMagicMetadata!.editedTime != null) {
|
||||
// override existing creationTime to avoid re-writing all queries related
|
||||
// to loading the gallery
|
||||
row[columnCreationTime] = trashFile.pubMagicMetadata!.editedTime!;
|
||||
}
|
||||
|
||||
return trashFile;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _getRowForTrash(TrashFile trash) {
|
||||
final row = <String, dynamic>{};
|
||||
row[columnTrashUpdatedAt] = trash.updateAt;
|
||||
row[columnTrashDeleteBy] = trash.deleteBy;
|
||||
row[columnUploadedFileID] = trash.uploadedFileID;
|
||||
row[columnCollectionID] = trash.collectionID;
|
||||
row[columnOwnerID] = trash.ownerID;
|
||||
row[columnEncryptedKey] = trash.encryptedKey;
|
||||
row[columnKeyDecryptionNonce] = trash.keyDecryptionNonce;
|
||||
row[columnFileDecryptionHeader] = trash.fileDecryptionHeader;
|
||||
row[columnThumbnailDecryptionHeader] = trash.thumbnailDecryptionHeader;
|
||||
row[columnUpdationTime] = trash.updationTime;
|
||||
|
||||
row[columnLocalID] = trash.localID;
|
||||
row[columnCreationTime] = trash.creationTime;
|
||||
row[columnFileMetadata] = jsonEncode(trash.metadata);
|
||||
|
||||
row[columnMMdVersion] = trash.mMdVersion;
|
||||
row[columnMMdEncodedJson] = trash.mMdEncodedJson ?? '{}';
|
||||
|
||||
row[columnPubMMdVersion] = trash.pubMmdVersion;
|
||||
row[columnPubMMdEncodedJson] = trash.pubMmdEncodedJson ?? '{}';
|
||||
return row;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import "package:photos/events/event.dart";
|
||||
|
||||
class FileCaptionUpdatedEvent extends Event {
|
||||
final int fileGeneratedID;
|
||||
final String fileTag;
|
||||
|
||||
FileCaptionUpdatedEvent(this.fileGeneratedID);
|
||||
FileCaptionUpdatedEvent(this.fileTag);
|
||||
}
|
||||
|
||||
10
mobile/apps/photos/lib/events/v1/LocalAssetChangedEvent.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import "package:photos/events/event.dart";
|
||||
|
||||
class LocalAssetChangedEvent extends Event {
|
||||
final String source;
|
||||
|
||||
LocalAssetChangedEvent(this.source);
|
||||
|
||||
@override
|
||||
String get reason => '$runtimeType{"via": $source}';
|
||||
}
|
||||
@@ -39,20 +39,26 @@ class EnteWatch extends Stopwatch {
|
||||
class TimeLogger {
|
||||
final String context;
|
||||
final int logThreshold;
|
||||
DateTime _start;
|
||||
TimeLogger({this.context = "TLog", this.logThreshold = 5})
|
||||
final DateTime _start;
|
||||
DateTime _toStringStart = DateTime.now();
|
||||
TimeLogger({this.context = "TLog:", this.logThreshold = 5})
|
||||
: _start = DateTime.now();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final int diff = DateTime.now().difference(_start).inMilliseconds;
|
||||
final int diff = DateTime.now().difference(_toStringStart).inMilliseconds;
|
||||
late String res;
|
||||
if (diff > logThreshold) {
|
||||
res = "[$context: $diff ms]";
|
||||
res = "[$context$diff ms]";
|
||||
} else {
|
||||
res = "[]";
|
||||
}
|
||||
_start = DateTime.now();
|
||||
_toStringStart = DateTime.now();
|
||||
return res;
|
||||
}
|
||||
|
||||
String get elapsed {
|
||||
final int diff = DateTime.now().difference(_start).inMilliseconds;
|
||||
return "[$context$diff ms]";
|
||||
}
|
||||
}
|
||||
|
||||
90
mobile/apps/photos/lib/image/in_memory_image_cache.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:photos/core/cache/lru_map.dart";
|
||||
import "package:photos/models/file/file.dart";
|
||||
|
||||
// Singleton instance for global access
|
||||
final enteImageCache = InMemoryImageCache._instance;
|
||||
|
||||
class InMemoryImageCache {
|
||||
static final InMemoryImageCache _instance = InMemoryImageCache._();
|
||||
|
||||
// Private constructor for singleton
|
||||
InMemoryImageCache._();
|
||||
|
||||
// Supported dimensions with associated cache sizes
|
||||
static const Map<int, int> _cacheSizes = {
|
||||
32: 5000, // Small: 32*32 = 1024 bytes * 5000 = 6.25MB
|
||||
256: 2000, // Medium: 256*256 = 65536 bytes * 2000 = 128MB
|
||||
512: 100, // Large: 512*512 = 262144 bytes * 100 = 25MB
|
||||
};
|
||||
|
||||
// Cache instances for each dimension
|
||||
final Map<int, LRUMap<String, Uint8List?>> _caches = {
|
||||
32: LRUMap<String, Uint8List?>(5000),
|
||||
256: LRUMap<String, Uint8List?>(2000),
|
||||
512: LRUMap<String, Uint8List?>(100),
|
||||
};
|
||||
|
||||
/// Gets a thumbnail for a file at the specified dimension
|
||||
Uint8List? getThumb(EnteFile file, int dimension) {
|
||||
return _getFromCache(file.cacheKey(), dimension);
|
||||
}
|
||||
|
||||
/// Gets a thumbnail by ID at the specified dimension
|
||||
Uint8List? getThumbByID(String id, int dimension) {
|
||||
return _getFromCache(id, dimension);
|
||||
}
|
||||
|
||||
/// Stores a thumbnail for a file at the specified dimension
|
||||
void putThumb(EnteFile file, Uint8List? imageData, int dimension) {
|
||||
_putInCache(file.cacheKey(), imageData, dimension);
|
||||
}
|
||||
|
||||
/// Stores a thumbnail by ID at the specified dimension
|
||||
void putThumbByID(String id, Uint8List? imageData, int dimension) {
|
||||
_putInCache(id, imageData, dimension);
|
||||
}
|
||||
|
||||
/// Checks if a thumbnail exists for a file at the specified dimension
|
||||
bool containsThumb(EnteFile file, int dimension) {
|
||||
return _isCached(file.cacheKey(), dimension);
|
||||
}
|
||||
|
||||
void clearCache(EnteFile file) {
|
||||
_caches.forEach((_, cache) {
|
||||
cache.remove(file.cacheKey());
|
||||
});
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
Uint8List? _getFromCache(String key, int dimension) {
|
||||
if (_isValidDimension(dimension)) {
|
||||
return _caches[dimension]?.get(key);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _putInCache(String key, Uint8List? imageData, int dimension) {
|
||||
if (_isValidDimension(dimension)) {
|
||||
_caches[dimension]?.put(key, imageData);
|
||||
} else {
|
||||
debugPrint("Unsupported dimension: $dimension");
|
||||
}
|
||||
}
|
||||
|
||||
bool _isCached(String key, int dimension) {
|
||||
if (_isValidDimension(dimension)) {
|
||||
return _caches[dimension]?.containsKey(key) ?? false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _isValidDimension(int dimension) {
|
||||
if (_caches.containsKey(dimension)) {
|
||||
return true;
|
||||
}
|
||||
debugPrint("Invalid dimension: $dimension");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
193
mobile/apps/photos/lib/image/provider/local_thumbnail_img.dart
Normal file
@@ -0,0 +1,193 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import "package:equatable/equatable.dart";
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import "package:photos/image/in_memory_image_cache.dart";
|
||||
import "package:photos/utils/standalone/task_queue.dart";
|
||||
|
||||
final thumbnailQueue = TaskQueue<String>(
|
||||
maxConcurrentTasks: 15,
|
||||
taskTimeout: const Duration(minutes: 1),
|
||||
maxQueueSize: 200, // Limit the queue to 50 pending tasks
|
||||
);
|
||||
|
||||
final mediumThumbnailQueue = TaskQueue<String>(
|
||||
maxConcurrentTasks: 5,
|
||||
taskTimeout: const Duration(minutes: 1),
|
||||
maxQueueSize: 200, // Limit the queue to 50 pending tasks
|
||||
);
|
||||
|
||||
class LocalThumbnailProvider extends ImageProvider<LocalThumbnailProviderKey> {
|
||||
final LocalThumbnailProviderKey key;
|
||||
final int maxRetries;
|
||||
final Duration retryDelay;
|
||||
|
||||
LocalThumbnailProvider(
|
||||
this.key, {
|
||||
this.maxRetries = 300,
|
||||
this.retryDelay = const Duration(milliseconds: 5),
|
||||
});
|
||||
|
||||
@override
|
||||
Future<LocalThumbnailProviderKey> obtainKey(
|
||||
ImageConfiguration configuration,
|
||||
) async {
|
||||
return SynchronousFuture<LocalThumbnailProviderKey>(key);
|
||||
}
|
||||
|
||||
static cancelRequest(LocalThumbnailProviderKey key) {
|
||||
thumbnailQueue.removeTask('${key.asset.id}-small');
|
||||
mediumThumbnailQueue.removeTask('${key.asset.id}-medium');
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
LocalThumbnailProviderKey key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||
return MultiImageStreamCompleter(
|
||||
codec: _codec(key, decode, chunkEvents),
|
||||
scale: 1.0,
|
||||
chunkEvents: chunkEvents.stream,
|
||||
informationCollector: () sync* {
|
||||
yield ErrorDescription('id: ${key.asset.id} name: ${key.asset.title}');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Stream<ui.Codec> _codec(
|
||||
LocalThumbnailProviderKey key,
|
||||
ImageDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent> chunkEvents,
|
||||
) async* {
|
||||
// First try to get from cache
|
||||
Uint8List? normalThumbBytes =
|
||||
enteImageCache.getThumbByID(key.asset.id, key.height);
|
||||
if (normalThumbBytes != null) {
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(normalThumbBytes);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
chunkEvents.close().ignore();
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to load small thumbnail with retry logic
|
||||
final Uint8List? thumbBytes = await _loadWithRetry(
|
||||
key: key,
|
||||
size: ThumbnailSize(key.smallThumbWidth, key.smallThumbHeight),
|
||||
quality: 75,
|
||||
cacheKey: '${key.asset.id}-small',
|
||||
queue: thumbnailQueue,
|
||||
cacheWidth: key.smallThumbWidth,
|
||||
);
|
||||
|
||||
if (thumbBytes != null) {
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
} else {
|
||||
debugPrint("$runtimeType smallThumb ${key.asset.title} failed");
|
||||
}
|
||||
|
||||
// Try to load normal thumbnail with retry logic if not already in cache
|
||||
if (normalThumbBytes == null) {
|
||||
normalThumbBytes = await _loadWithRetry(
|
||||
key: key,
|
||||
size: ThumbnailSize(key.width, key.height),
|
||||
quality: 50,
|
||||
cacheKey: '${key.asset.id}-medium',
|
||||
queue: mediumThumbnailQueue,
|
||||
cacheWidth: key.height,
|
||||
);
|
||||
|
||||
if (normalThumbBytes == null) {
|
||||
throw StateError("$runtimeType biThumb ${key.asset.title} failed");
|
||||
}
|
||||
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(normalThumbBytes);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
}
|
||||
|
||||
chunkEvents.close().ignore();
|
||||
}
|
||||
|
||||
Future<Uint8List?> _loadWithRetry({
|
||||
required LocalThumbnailProviderKey key,
|
||||
required ThumbnailSize size,
|
||||
required int quality,
|
||||
required String cacheKey,
|
||||
required TaskQueue<String> queue,
|
||||
required int cacheWidth,
|
||||
}) async {
|
||||
int attempt = 0;
|
||||
Uint8List? result;
|
||||
|
||||
while (attempt <= maxRetries) {
|
||||
try {
|
||||
// Check cache first on retry attempts
|
||||
if (attempt > 0) {
|
||||
result = enteImageCache.getThumbByID(key.asset.id, cacheWidth);
|
||||
if (result != null) return result;
|
||||
}
|
||||
|
||||
final Completer<Uint8List?> future = Completer();
|
||||
await queue.addTask(cacheKey, () async {
|
||||
final bytes =
|
||||
await key.asset.thumbnailDataWithSize(size, quality: quality);
|
||||
enteImageCache.putThumbByID(key.asset.id, bytes, cacheWidth);
|
||||
future.complete(bytes);
|
||||
});
|
||||
result = await future.future;
|
||||
return result;
|
||||
} catch (e) {
|
||||
// Only retry on specific exceptions
|
||||
if (e is! TaskQueueOverflowException &&
|
||||
e is! TaskQueueTimeoutException &&
|
||||
e is! TaskQueueCancelledException) {
|
||||
rethrow;
|
||||
}
|
||||
|
||||
attempt++;
|
||||
if (attempt <= maxRetries) {
|
||||
await Future.delayed(retryDelay * attempt); // Exponential backoff
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class LocalThumbnailProviderKey extends Equatable {
|
||||
final AssetEntity asset;
|
||||
final int height;
|
||||
final int width;
|
||||
final int smallThumbHeight;
|
||||
final int smallThumbWidth;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
asset.id,
|
||||
asset.modifiedDateSecond ?? 0,
|
||||
height,
|
||||
width,
|
||||
smallThumbHeight,
|
||||
smallThumbWidth,
|
||||
];
|
||||
|
||||
const LocalThumbnailProviderKey({
|
||||
required this.asset,
|
||||
this.height = 256,
|
||||
this.width = 256,
|
||||
this.smallThumbWidth = 32,
|
||||
this.smallThumbHeight = 32,
|
||||
});
|
||||
}
|
||||
@@ -372,7 +372,7 @@
|
||||
"deleteFromBoth": "الحذف من كليهما",
|
||||
"newAlbum": "ألبوم جديد",
|
||||
"albums": "الألبومات",
|
||||
"memoryCount": "{count, plural, =0 {لا توجد ذكريات} one {ذكرى واحدة} two {ذكريتان} few {{formattedCount} ذكريات} many {{formattedCount} ذكرى} other {{formattedCount} ذكرى}}",
|
||||
"memoryCount": "{count, plural, =0 {لا توجد ذكريات} one {ذكرى واحدة} two {ذكريتان} other {{formattedCount} ذكرى}}",
|
||||
"@memoryCount": {
|
||||
"description": "The text to display the number of memories",
|
||||
"type": "text",
|
||||
@@ -460,7 +460,7 @@
|
||||
"skip": "تخط",
|
||||
"updatingFolderSelection": "جارٍ تحديث تحديد المجلد...",
|
||||
"itemCount": "{count, plural, one {{count} عُنْصُر} other {{count} عَنَاصِر}}",
|
||||
"deleteItemCount": "{count, plural, =1 {حذف عنصر واحد} two {حذف عنصرين} few {حذف {count} عناصر} many {حذف {count} عنصرًا} other {حذف {count} عنصرًا}}",
|
||||
"deleteItemCount": "{count, plural, =1 {حذف عنصر واحد} two {حذف عنصرين} other {حذف {count} عنصرًا}}",
|
||||
"duplicateItemsGroup": "{count} ملفات، {formattedSize} لكل منها",
|
||||
"@duplicateItemsGroup": {
|
||||
"description": "Display the number of duplicate files and their size",
|
||||
@@ -477,7 +477,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "عرض الذكريات",
|
||||
"yearsAgo": "{count, plural, one {قبل سنة} two {قبل سنتين} few {قبل {count} سنوات} many {قبل {count} سنة} other {قبل {count} سنة}}",
|
||||
"yearsAgo": "{count, plural, one {قبل سنة} two {قبل سنتين} other {قبل {count} سنة}}",
|
||||
"backupSettings": "إعدادات النسخ الاحتياطي",
|
||||
"backupStatus": "حالة النسخ الاحتياطي",
|
||||
"backupStatusDescription": "ستظهر العناصر التي تم نسخها احتياطيًا هنا",
|
||||
@@ -543,7 +543,7 @@
|
||||
},
|
||||
"remindToEmptyEnteTrash": "تذكر أيضًا إفراغ \"سلة المهملات\" لاستعادة المساحة المحررة.",
|
||||
"sparkleSuccess": "✨ نجاح",
|
||||
"duplicateFileCountWithStorageSaved": "لقد قمت بتنظيف {count, plural, one {ملف مكرر واحد} two {ملفين مكررين} few {{count} ملفات مكررة} many {{count} ملفًا مكررًا} other {{count} ملفًا مكررًا}}، مما وفر {storageSaved}!",
|
||||
"duplicateFileCountWithStorageSaved": "لقد قمت بتنظيف {count, plural, one {ملف مكرر واحد} two {ملفين مكررين} other {{count} ملفًا مكررًا}}، مما وفر {storageSaved}!",
|
||||
"@duplicateFileCountWithStorageSaved": {
|
||||
"description": "The text to display when the user has successfully cleaned up duplicate files",
|
||||
"type": "text",
|
||||
@@ -794,11 +794,11 @@
|
||||
"share": "مشاركة",
|
||||
"unhideToAlbum": "إظهار في الألبوم",
|
||||
"restoreToAlbum": "استعادة إلى الألبوم",
|
||||
"moveItem": "{count, plural, =1 {نقل عنصر} two {نقل عنصرين} few {نقل {count} عناصر} many {نقل {count} عنصرًا} other {نقل {count} عنصرًا}}",
|
||||
"moveItem": "{count, plural, =1 {نقل عنصر} two {نقل عنصرين} other {نقل {count} عنصرًا}}",
|
||||
"@moveItem": {
|
||||
"description": "Page title while moving one or more items to an album"
|
||||
},
|
||||
"addItem": "{count, plural, =1 {إضافة عنصر} two {إضافة عنصرين} few {إضافة {count} عناصر} many {إضافة {count} عنصرًا} other {إضافة {count} عنصرًا}}",
|
||||
"addItem": "{count, plural, =1 {إضافة عنصر} two {إضافة عنصرين} other {إضافة {count} عنصرًا}}",
|
||||
"@addItem": {
|
||||
"description": "Page title while adding one or more items to album"
|
||||
},
|
||||
@@ -826,7 +826,7 @@
|
||||
"referFriendsAnd2xYourPlan": "أحِل الأصدقاء وضاعف خطتك مرتين",
|
||||
"shareAlbumHint": "افتح ألبومًا وانقر على زر المشاركة في الزاوية اليمنى العليا للمشاركة.",
|
||||
"itemsShowTheNumberOfDaysRemainingBeforePermanentDeletion": "تعرض العناصر عدد الأيام المتبقية قبل الحذف الدائم.",
|
||||
"trashDaysLeft": "{count, plural, =0 {قريبًا} =1 {يوم واحد} two {يومان} few {{count} أيام} many {{count} يومًا} other {{count} يومًا}}",
|
||||
"trashDaysLeft": "{count, plural, =0 {قريبًا} =1 {يوم واحد} two {يومان} other {{count} يومًا}}",
|
||||
"@trashDaysLeft": {
|
||||
"description": "Text to indicate number of days remaining before permanent deletion",
|
||||
"placeholders": {
|
||||
@@ -899,8 +899,8 @@
|
||||
"authToViewYourMemories": "يرجى المصادقة لعرض ذكرياتك.",
|
||||
"unlock": "فتح",
|
||||
"freeUpSpace": "تحرير المساحة",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {يمكن حذفه من الجهاز لتحرير {formattedSize}} two {يمكن حذفهما من الجهاز لتحرير {formattedSize}} few {يمكن حذفها من الجهاز لتحرير {formattedSize}} many {يمكن حذفها من الجهاز لتحرير {formattedSize}} other {يمكن حذفها من الجهاز لتحرير {formattedSize}}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {ملف واحد} two {ملفان} few {{formattedNumber} ملفات} many {{formattedNumber} ملفًا} other {{formattedNumber} ملفًا}} في هذا الألبوم تم نسخه احتياطيًا بأمان",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {يمكن حذفه من الجهاز لتحرير {formattedSize}} two {يمكن حذفهما من الجهاز لتحرير {formattedSize}} other {يمكن حذفها من الجهاز لتحرير {formattedSize}}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {ملف واحد} two {ملفان} other {{formattedNumber} ملفًا}} في هذا الألبوم تم نسخه احتياطيًا بأمان",
|
||||
"@filesBackedUpInAlbum": {
|
||||
"description": "Text to tell user how many files have been backed up in the album",
|
||||
"placeholders": {
|
||||
@@ -915,7 +915,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"filesBackedUpFromDevice": "{count, plural, one {ملف واحد} two {ملفان} few {{formattedNumber} ملفات} many {{formattedNumber} ملفًا} other {{formattedNumber} ملفًا}} على هذا الجهاز تم نسخه احتياطيًا بأمان",
|
||||
"filesBackedUpFromDevice": "{count, plural, one {ملف واحد} two {ملفان} other {{formattedNumber} ملفًا}} على هذا الجهاز تم نسخه احتياطيًا بأمان",
|
||||
"@filesBackedUpFromDevice": {
|
||||
"description": "Text to tell user how many files have been backed up from this device",
|
||||
"placeholders": {
|
||||
@@ -1217,7 +1217,7 @@
|
||||
"searchHint4": "الموقع",
|
||||
"searchHint5": "قريبًا: الوجوه والبحث السحري ✨",
|
||||
"addYourPhotosNow": "أضف صورك الآن",
|
||||
"searchResultCount": "{count, plural, one{{count} النتائج التي تم العثور عليها} other{{count} النتائج التي تم العثور عليها}}",
|
||||
"searchResultCount": "{count, plural, other{{count} النتائج التي تم العثور عليها}}",
|
||||
"@searchResultCount": {
|
||||
"description": "Text to tell user how many results were found for their search query",
|
||||
"placeholders": {
|
||||
@@ -1269,8 +1269,8 @@
|
||||
"description": "Subtitle to indicate that the user can find people quickly by name"
|
||||
},
|
||||
"findPeopleByName": "البحث عن الأشخاص بسرعة بالاسم",
|
||||
"addViewers": "{count, plural, =0 {إضافة مشاهد} =1 {إضافة مشاهد} two {إضافة مشاهدين} few {إضافة {count} مشاهدين} many {إضافة {count} مشاهدًا} other {إضافة {count} مشاهدًا}}",
|
||||
"addCollaborators": "{count, plural, =0 {إضافة متعاون} =1 {إضافة متعاون} two {إضافة متعاونين} few {إضافة {count} متعاونين} many {إضافة {count} متعاونًا} other {إضافة {count} متعاونًا}}",
|
||||
"addViewers": "{count, plural, =0 {إضافة مشاهد} =1 {إضافة مشاهد} two {إضافة مشاهدين} other {إضافة {count} مشاهدًا}}",
|
||||
"addCollaborators": "{count, plural, =0 {إضافة متعاون} =1 {إضافة متعاون} two {إضافة متعاونين} other {إضافة {count} متعاونًا}}",
|
||||
"longPressAnEmailToVerifyEndToEndEncryption": "اضغط مطولاً على بريد إلكتروني للتحقق من التشفير من طرف إلى طرف.",
|
||||
"developerSettingsWarning": "هل أنت متأكد من رغبتك في تعديل إعدادات المطور؟",
|
||||
"developerSettings": "إعدادات المطور",
|
||||
@@ -1403,7 +1403,7 @@
|
||||
"enableMachineLearningBanner": "قم بتمكين تعلم الآلة للبحث السحري والتعرف على الوجوه.",
|
||||
"searchDiscoverEmptySection": "سيتم عرض الصور هنا بمجرد اكتمال المعالجة والمزامنة.",
|
||||
"searchPersonsEmptySection": "سيتم عرض الأشخاص هنا بمجرد اكتمال المعالجة والمزامنة.",
|
||||
"viewersSuccessfullyAdded": "{count, plural, =0 {تمت إضافة 0 مشاهدين} =1 {تمت إضافة مشاهد واحد} two {تمت إضافة مشاهدين} few {تمت إضافة {count} مشاهدين} many {تمت إضافة {count} مشاهدًا} other {تمت إضافة {count} مشاهدًا}}",
|
||||
"viewersSuccessfullyAdded": "{count, plural, =0 {تمت إضافة 0 مشاهدين} =1 {تمت إضافة مشاهد واحد} two {تمت إضافة مشاهدين} other {تمت إضافة {count} مشاهدًا}}",
|
||||
"@viewersSuccessfullyAdded": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1413,7 +1413,7 @@
|
||||
},
|
||||
"description": "Number of viewers that were successfully added to an album."
|
||||
},
|
||||
"collaboratorsSuccessfullyAdded": "{count, plural, =0 {تمت إضافة 0 متعاونين} =1 {تمت إضافة متعاون واحد} two {تمت إضافة متعاونين} few {تمت إضافة {count} متعاونين} many {تمت إضافة {count} متعاونًا} other {تمت إضافة {count} متعاونًا}}",
|
||||
"collaboratorsSuccessfullyAdded": "{count, plural, =0 {تمت إضافة 0 متعاونين} =1 {تمت إضافة متعاون واحد} two {تمت إضافة متعاونين} other {تمت إضافة {count} متعاونًا}}",
|
||||
"@collaboratorsSuccessfullyAdded": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1488,7 +1488,7 @@
|
||||
},
|
||||
"currentlyRunning": "قيد التشغيل حاليًا",
|
||||
"ignored": "تم التجاهل",
|
||||
"photosCount": "{count, plural, =0 {لا توجد صور} =1 {صورة واحدة} two {صورتان} few {{count} صور} many {{count} صورة} other {{count} صورة}}",
|
||||
"photosCount": "{count, plural, =0 {لا توجد صور} =1 {صورة واحدة} two {صورتان} other {{count} صورة}}",
|
||||
"@photosCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1686,7 +1686,7 @@
|
||||
"moveSelectedPhotosToOneDate": "نقل الصور المحددة إلى تاريخ واحد",
|
||||
"shiftDatesAndTime": "تغيير التواريخ والوقت",
|
||||
"photosKeepRelativeTimeDifference": "تحتفظ الصور بالفرق الزمني النسبي",
|
||||
"photocountPhotos": "{count, plural, =0 {لا توجد صور} =1 {صورة واحدة} two {صورتان} few {{count} صور} many {{count} صورة} other {{count} صورة}}",
|
||||
"photocountPhotos": "{count, plural, =0 {لا توجد صور} =1 {صورة واحدة} two {صورتان} other {{count} صورة}}",
|
||||
"@photocountPhotos": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1700,7 +1700,7 @@
|
||||
"selectedItemsWillBeRemovedFromThisPerson": "سيتم إزالة العناصر المحددة من هذا الشخص، ولكن لن يتم حذفها من مكتبتك.",
|
||||
"throughTheYears": "{dateFormat} عبر السنين",
|
||||
"thisWeekThroughTheYears": "هذا الأسبوع عبر السنين",
|
||||
"thisWeekXYearsAgo": "{count, plural, =1 {هذا الأسبوع، قبل سنة} two {هذا الأسبوع، قبل سنتين} few {هذا الأسبوع، قبل {count} سنوات} many {هذا الأسبوع، قبل {count} سنة} other {هذا الأسبوع، قبل {count} سنة}}",
|
||||
"thisWeekXYearsAgo": "{count, plural, =1 {هذا الأسبوع، قبل سنة} two {هذا الأسبوع، قبل سنتين} other {هذا الأسبوع، قبل {count} سنة}}",
|
||||
"youAndThem": "أنت و {name}",
|
||||
"admiringThem": "الإعجاب بـ {name}",
|
||||
"embracingThem": "معانقة {name}",
|
||||
@@ -1776,6 +1776,11 @@
|
||||
"same": "نفس",
|
||||
"different": "مختلف",
|
||||
"sameperson": "نفس الشخص؟",
|
||||
"cLTitle1": "محرر الصور المتقدم",
|
||||
"cLDesc1": "نحن بصدد إطلاق محرر صور جديد ومتقدم يضيف المزيد من إطارات الاقتصاص، والإعدادات المسبقة للفلاتر من أجل تعديلات سريعة، وخيارات الضبط الدقيق التي تشمل التشبع، والتباين، والسطوع، ودرجة الحرارة، وغير ذلك الكثير. يتضمن المحرر الجديد أيضا القدرة على الرسم على صورك وإضافة الرموز التعبيرية كملصقات.",
|
||||
"cLTitle2": "ألبومات ذكية",
|
||||
"cLTitle3": "معرض محسن",
|
||||
"cLTitle4": "تمرير أسرع",
|
||||
"thisWeek": "هذا الأسبوع",
|
||||
"lastWeek": "الأسبوع الماضي",
|
||||
"thisMonth": "هذا الشهر",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 - "
|
||||
}
|
||||
|
||||
@@ -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í"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -389,8 +389,8 @@
|
||||
"selectAll": "בחר הכל",
|
||||
"skip": "דלג",
|
||||
"updatingFolderSelection": "מעדכן את בחירת התיקיות...",
|
||||
"itemCount": "{count, plural, one{{count} פריט} two {{count} פריטים} many {{count} פריטים} other{{count} פריטים}}",
|
||||
"deleteItemCount": "{count, plural, =1 {מחק {count} פריט} two {מחק {count} פריטים} other {מחק {count} פריטים}}",
|
||||
"itemCount": "{count, plural, one{{count} פריט} other{{count} פריטים}}",
|
||||
"deleteItemCount": "{count, plural, =1 {מחק {count} פריט} other {מחק {count} פריטים}}",
|
||||
"duplicateItemsGroup": "{count} קבצים, כל אחד {formattedSize}",
|
||||
"@duplicateItemsGroup": {
|
||||
"description": "Display the number of duplicate files and their size",
|
||||
@@ -407,7 +407,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "הצג זכרונות",
|
||||
"yearsAgo": "{count, plural, one{לפני {count} שנה} two {לפני {count} שנים} many {לפני {count} שנים} other{לפני {count} שנים}}",
|
||||
"yearsAgo": "{count, plural, one{לפני {count} שנה} other{לפני {count} שנים}}",
|
||||
"backupSettings": "הגדרות גיבוי",
|
||||
"backupOverMobileData": "גבה על רשת סלולרית",
|
||||
"backupVideos": "גבה סרטונים",
|
||||
@@ -792,4 +792,4 @@
|
||||
"create": "צור",
|
||||
"viewAll": "הצג הכל",
|
||||
"hiding": "מחביא..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,7 +458,7 @@
|
||||
"selectAll": "Összes kijelölése",
|
||||
"skip": "Kihagyás",
|
||||
"updatingFolderSelection": "Mappakijelölés frissítése...",
|
||||
"itemCount": "{count, plural, one{{count} elem} other{{count} elem}}",
|
||||
"itemCount": "{count, plural, other{{count} elem}}",
|
||||
"deleteItemCount": "{count, plural, =1 {Elem {count} törlése} other {Elemek {count} törlése}}",
|
||||
"duplicateItemsGroup": "{count} fájl, {formattedSize} mindegyik",
|
||||
"@duplicateItemsGroup": {
|
||||
@@ -541,4 +541,4 @@
|
||||
}
|
||||
},
|
||||
"remindToEmptyEnteTrash": "Ürítsd ki a \"Kukát\" is, hogy visszaszerezd a felszabadult helyet."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"deleteFromBoth": "Hapus dari keduanya",
|
||||
"newAlbum": "Album baru",
|
||||
"albums": "Album",
|
||||
"memoryCount": "{count, plural, =0{tidak ada memori} one{{formattedCount} memori} other{{formattedCount} memori}}",
|
||||
"memoryCount": "{count, plural, =0{tidak ada memori} other{{formattedCount} memori}}",
|
||||
"@memoryCount": {
|
||||
"description": "The text to display the number of memories",
|
||||
"type": "text",
|
||||
@@ -1234,4 +1234,4 @@
|
||||
"left": "Kiri",
|
||||
"right": "Kanan",
|
||||
"whatsNew": "Hal yang baru"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -794,11 +794,11 @@
|
||||
"share": "Condividi",
|
||||
"unhideToAlbum": "Non nascondere l'album",
|
||||
"restoreToAlbum": "Ripristina l'album",
|
||||
"moveItem": "{count, plural, one {}=1 {Sposta elemento} other {Sposta elementi}}",
|
||||
"moveItem": "{count, plural, =1 {Sposta elemento} other {Sposta elementi}}",
|
||||
"@moveItem": {
|
||||
"description": "Page title while moving one or more items to an album"
|
||||
},
|
||||
"addItem": "{count, plural, one {}=1 {Aggiungi elemento} other {Aggiungi elementi}}",
|
||||
"addItem": "{count, plural, =1 {Aggiungi elemento} other {Aggiungi elementi}}",
|
||||
"@addItem": {
|
||||
"description": "Page title while adding one or more items to album"
|
||||
},
|
||||
@@ -899,7 +899,7 @@
|
||||
"authToViewYourMemories": "Autenticati per visualizzare le tue foto",
|
||||
"unlock": "Sblocca",
|
||||
"freeUpSpace": "Libera spazio",
|
||||
"freeUpSpaceSaving": "{count, plural, one {}=1 {Può essere cancellato per liberare {formattedSize}} other {Possono essere cancellati per liberare {formattedSize}}}",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {Può essere cancellato per liberare {formattedSize}} other {Possono essere cancellati per liberare {formattedSize}}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {1 file} other {{formattedNumber} file}} di quest'album sono stati salvati in modo sicuro",
|
||||
"@filesBackedUpInAlbum": {
|
||||
"description": "Text to tell user how many files have been backed up in the album",
|
||||
@@ -1260,8 +1260,8 @@
|
||||
"description": "Subtitle to indicate that the user can find people quickly by name"
|
||||
},
|
||||
"findPeopleByName": "Trova rapidamente le persone per nome",
|
||||
"addViewers": "{count, plural, one {}=0 {Aggiungi visualizzatore} =1 {Add viewer} other {Aggiungi visualizzatori}}",
|
||||
"addCollaborators": "{count, plural, one {}=0 {Aggiungi collaboratore} =1 {Aggiungi collaboratore} other {Aggiungi collaboratori}}",
|
||||
"addViewers": "{count, plural, =0 {Aggiungi visualizzatore} =1 {Add viewer} other {Aggiungi visualizzatori}}",
|
||||
"addCollaborators": "{count, plural, =0 {Aggiungi collaboratore} =1 {Aggiungi collaboratore} other {Aggiungi collaboratori}}",
|
||||
"longPressAnEmailToVerifyEndToEndEncryption": "Premi a lungo un'email per verificare la crittografia end to end.",
|
||||
"developerSettingsWarning": "Sei sicuro di voler modificare le Impostazioni sviluppatore?",
|
||||
"developerSettings": "Impostazioni sviluppatore",
|
||||
@@ -1394,7 +1394,7 @@
|
||||
"enableMachineLearningBanner": "Abilita l'apprendimento automatico per la ricerca magica e il riconoscimento facciale",
|
||||
"searchDiscoverEmptySection": "Le immagini saranno mostrate qui una volta che l'elaborazione e la sincronizzazione saranno completate",
|
||||
"searchPersonsEmptySection": "Le persone saranno mostrate qui una volta che l'elaborazione e la sincronizzazione saranno completate",
|
||||
"viewersSuccessfullyAdded": "{count, plural, one {}=0 {Added 0 visualizzatori} =1 {Added 1 visualizzatore} other {Added {count} visualizzatori}}",
|
||||
"viewersSuccessfullyAdded": "{count, plural, =0 {Added 0 visualizzatori} =1 {Added 1 visualizzatore} other {Added {count} visualizzatori}}",
|
||||
"@viewersSuccessfullyAdded": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1479,7 +1479,7 @@
|
||||
},
|
||||
"currentlyRunning": "attualmente in esecuzione",
|
||||
"ignored": "ignorato",
|
||||
"photosCount": "{count, plural, one {}=0 {0 foto} =1 {1 foto} other {{count} foto}}",
|
||||
"photosCount": "{count, plural, =0 {0 foto} =1 {1 foto} other {{count} foto}}",
|
||||
"@photosCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1677,7 +1677,7 @@
|
||||
"moveSelectedPhotosToOneDate": "Sposta foto selezionate in una data specifica",
|
||||
"shiftDatesAndTime": "Sposta date e orari",
|
||||
"photosKeepRelativeTimeDifference": "Le foto mantengono una differenza di tempo relativa",
|
||||
"photocountPhotos": "{count, plural, one {}=0 {Nessuna foto} =1 {1 foto} other {{count} foto}}",
|
||||
"photocountPhotos": "{count, plural, =0 {Nessuna foto} =1 {1 foto} other {{count} foto}}",
|
||||
"@photocountPhotos": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1691,7 +1691,7 @@
|
||||
"selectedItemsWillBeRemovedFromThisPerson": "Gli elementi selezionati verranno rimossi da questa persona, ma non eliminati dalla tua libreria.",
|
||||
"throughTheYears": "{dateFormat} negli anni",
|
||||
"thisWeekThroughTheYears": "Questa settimana negli anni",
|
||||
"thisWeekXYearsAgo": "{count, plural, one {}=1 {Questa settimana, {count} anno fa} other {Questa settimana, {count} anni fa}}",
|
||||
"thisWeekXYearsAgo": "{count, plural, =1 {Questa settimana, {count} anno fa} other {Questa settimana, {count} anni fa}}",
|
||||
"youAndThem": "Tu e {name}",
|
||||
"admiringThem": "Ammirando {name}",
|
||||
"embracingThem": "Abbracciando {name}",
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "毛むくじゃらな仲間たち"
|
||||
}
|
||||
|
||||
@@ -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ę",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@
|
||||
"deleteFromBoth": "Slett frå begge",
|
||||
"newAlbum": "Nytt album",
|
||||
"albums": "Albums",
|
||||
"memoryCount": "{count, plural, =0{ingen minne} one{{formattedCount} minne} other{{formattedCount} minne}}",
|
||||
"memoryCount": "{count, plural, =0{ingen minne} other{{formattedCount} minne}}",
|
||||
"@memoryCount": {
|
||||
"description": "The text to display the number of memories",
|
||||
"type": "text",
|
||||
@@ -310,4 +310,4 @@
|
||||
"adjust": "Juster",
|
||||
"draw": "Klistremerke",
|
||||
"brushColor": "Penselfarge"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,7 +477,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "Vis minner",
|
||||
"yearsAgo": "{count, plural, one{{count} år siden} other{{count} år siden}}",
|
||||
"yearsAgo": "{count, plural, other{{count} år siden}}",
|
||||
"backupSettings": "Sikkerhetskopier innstillinger",
|
||||
"backupStatus": "Status for sikkerhetskopi",
|
||||
"backupStatusDescription": "Elementer som har blitt sikkerhetskopiert vil vises her",
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Шрифт"
|
||||
}
|
||||
|
||||
@@ -445,7 +445,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "Прикажи успомене",
|
||||
"yearsAgo": "{count, plural, one{{count} година уназад} few {{count} године уназад} other{{count} година уназад}}",
|
||||
"yearsAgo": "{count, plural, other{{count} година уназад}}",
|
||||
"backupStatus": "Статус резервних копија",
|
||||
"backupOverMobileData": "Копирај користећи мобилни интернет",
|
||||
"backupVideos": "Копирај видео снимке",
|
||||
@@ -496,7 +496,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"duplicateFileCountWithStorageSaved": "Обрисали сте {count, plural, one{{count} дупликат} few {{count} дупликата} other{{count} дупликата}}, ослобађам ({storageSaved}!)",
|
||||
"duplicateFileCountWithStorageSaved": "Обрисали сте {count, plural, one{{count} дупликат} other{{count} дупликата}}, ослобађам ({storageSaved}!)",
|
||||
"@duplicateFileCountWithStorageSaved": {
|
||||
"description": "The text to display when the user has successfully cleaned up duplicate files",
|
||||
"type": "text",
|
||||
@@ -921,4 +921,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,7 +459,7 @@
|
||||
"selectAll": "Markera allt",
|
||||
"skip": "Hoppa över",
|
||||
"updatingFolderSelection": "Uppdaterar mappval...",
|
||||
"itemCount": "{count, plural, one{{count} objekt} other{{count} objekt}}",
|
||||
"itemCount": "{count, plural, other{{count} objekt}}",
|
||||
"deleteItemCount": "{count, plural, =1 {Radera {count} objekt} other {Radera {count} objekt}}",
|
||||
"duplicateItemsGroup": "{count} filer, {formattedSize} vardera",
|
||||
"@duplicateItemsGroup": {
|
||||
@@ -477,7 +477,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "Visa minnen",
|
||||
"yearsAgo": "{count, plural, one{{count} år sedan} other{{count} år sedan}}",
|
||||
"yearsAgo": "{count, plural, other{{count} år sedan}}",
|
||||
"backupSettings": "Säkerhetskopieringsinställningar",
|
||||
"backupStatus": "Säkerhetskopieringsstatus",
|
||||
"backupStatusDescription": "Objekt som har säkerhetskopierats kommer att visas här",
|
||||
@@ -619,7 +619,7 @@
|
||||
"viewAll": "Visa alla",
|
||||
"inviteYourFriendsToEnte": "Bjud in dina vänner till Ente",
|
||||
"fileTypes": "Filtyper",
|
||||
"searchResultCount": "{count, plural, one{{count} resultat hittades} other{{count} resultat hittades}}",
|
||||
"searchResultCount": "{count, plural, other{{count} resultat hittades}}",
|
||||
"@searchResultCount": {
|
||||
"description": "Text to tell user how many results were found for their search query",
|
||||
"placeholders": {
|
||||
@@ -655,4 +655,4 @@
|
||||
"newPerson": "Ny person",
|
||||
"addName": "Lägg till namn",
|
||||
"add": "Lägg till"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"deleteFromBoth": "Her ikisinden de sil",
|
||||
"newAlbum": "Yeni albüm",
|
||||
"albums": "Albümler",
|
||||
"memoryCount": "{count, plural, =0{hiç anı yok} one{{formattedCount} anı} other{{formattedCount} anı}}",
|
||||
"memoryCount": "{count, plural, =0{hiç anı yok} other{{formattedCount} anı}}",
|
||||
"@memoryCount": {
|
||||
"description": "The text to display the number of memories",
|
||||
"type": "text",
|
||||
@@ -477,7 +477,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "Anıları göster",
|
||||
"yearsAgo": "{count, plural, one{{count} yıl önce} other{{count} yıl önce}}",
|
||||
"yearsAgo": "{count, plural, other{{count} yıl önce}}",
|
||||
"backupSettings": "Yedekleme seçenekleri",
|
||||
"backupStatus": "Yedekleme durumu",
|
||||
"backupStatusDescription": "Eklenen öğeler burada görünecek",
|
||||
@@ -1217,7 +1217,7 @@
|
||||
"searchHint4": "Konum",
|
||||
"searchHint5": "Çok yakında: Yüzler ve sihirli arama ✨",
|
||||
"addYourPhotosNow": "Fotoğraflarınızı şimdi ekleyin",
|
||||
"searchResultCount": "{count, plural, one{{count} yıl önce} other{{count} yıl önce}}",
|
||||
"searchResultCount": "{count, plural, other{{count} yıl önce}}",
|
||||
"@searchResultCount": {
|
||||
"description": "Text to tell user how many results were found for their search query",
|
||||
"placeholders": {
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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": "Виникли проблеми з відтворенням цього відео? Натисніть і утримуйте тут, щоб спробувати інший плеєр."
|
||||
}
|
||||
|
||||
@@ -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 - "
|
||||
}
|
||||
@@ -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": "这里没什么可清理的"
|
||||
}
|
||||
|
||||
3
mobile/apps/photos/lib/log/devlog.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
import "dart:developer";
|
||||
|
||||
var devLog = log;
|
||||
@@ -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();
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class CollectionFileItem {
|
||||
final int id;
|
||||
final String encryptedKey;
|
||||
final String keyDecryptionNonce;
|
||||
|
||||
CollectionFileItem(
|
||||
this.id,
|
||||
this.encryptedKey,
|
||||
this.keyDecryptionNonce,
|
||||
);
|
||||
|
||||
CollectionFileItem copyWith({
|
||||
int? id,
|
||||
String? encryptedKey,
|
||||
String? keyDecryptionNonce,
|
||||
}) {
|
||||
return CollectionFileItem(
|
||||
id ?? this.id,
|
||||
encryptedKey ?? this.encryptedKey,
|
||||
keyDecryptionNonce ?? this.keyDecryptionNonce,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'encryptedKey': encryptedKey,
|
||||
'keyDecryptionNonce': keyDecryptionNonce,
|
||||
};
|
||||
}
|
||||
|
||||
static fromMap(Map<String, dynamic>? map) {
|
||||
if (map == null) return null;
|
||||
|
||||
return CollectionFileItem(
|
||||
map['id'],
|
||||
map['encryptedKey'],
|
||||
map['keyDecryptionNonce'],
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory CollectionFileItem.fromJson(String source) =>
|
||||
CollectionFileItem.fromMap(json.decode(source));
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'CollectionFileItem(id: $id, encryptedKey: $encryptedKey, keyDecryptionNonce: $keyDecryptionNonce)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object o) {
|
||||
if (identical(this, o)) return true;
|
||||
|
||||
return o is CollectionFileItem &&
|
||||
o.id == id &&
|
||||
o.encryptedKey == encryptedKey &&
|
||||
o.keyDecryptionNonce == keyDecryptionNonce;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
id.hashCode ^ encryptedKey.hashCode ^ keyDecryptionNonce.hashCode;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:ente_crypto/ente_crypto.dart";
|
||||
|
||||
class CollectionFileRequest {
|
||||
final int id;
|
||||
final String encryptedKey;
|
||||
final String keyDecryptionNonce;
|
||||
|
||||
CollectionFileRequest(
|
||||
this.id,
|
||||
this.encryptedKey,
|
||||
this.keyDecryptionNonce,
|
||||
);
|
||||
|
||||
static Map<String, dynamic> req(
|
||||
int id, {
|
||||
required Uint8List encKey,
|
||||
required Uint8List encKeyNonce,
|
||||
}) {
|
||||
return {
|
||||
'id': id,
|
||||
'encryptedKey': CryptoUtil.bin2base64(encKey),
|
||||
'keyDecryptionNonce': CryptoUtil.bin2base64(encKeyNonce),
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'encryptedKey': encryptedKey,
|
||||
'keyDecryptionNonce': keyDecryptionNonce,
|
||||
};
|
||||
}
|
||||
}
|
||||
197
mobile/apps/photos/lib/models/api/diff/diff.dart
Normal file
@@ -0,0 +1,197 @@
|
||||
import "dart:convert";
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:photos/models/api/diff/trash_time.dart";
|
||||
import "package:photos/models/file/remote/asset.dart";
|
||||
|
||||
class Info {
|
||||
final int fileSize;
|
||||
final int thumbSize;
|
||||
|
||||
static Info? fromJson(Map<String, dynamic>? json) {
|
||||
if (json == null) return null;
|
||||
return Info(
|
||||
fileSize: json['fileSize'] ?? -1,
|
||||
thumbSize: json['thumbSize'] ?? -1,
|
||||
);
|
||||
}
|
||||
|
||||
Info({required this.fileSize, required this.thumbSize});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'fileSize': fileSize,
|
||||
'thumbSize': thumbSize,
|
||||
};
|
||||
}
|
||||
|
||||
String toEncodedJson() {
|
||||
return jsonEncode(toJson());
|
||||
}
|
||||
|
||||
static Info? fromEncodedJson(String? encodedJson) {
|
||||
if (encodedJson == null) return null;
|
||||
return Info.fromJson(jsonDecode(encodedJson));
|
||||
}
|
||||
}
|
||||
|
||||
class Metadata {
|
||||
final Map<String, dynamic> data;
|
||||
final int version;
|
||||
|
||||
Metadata({required this.data, required this.version});
|
||||
|
||||
static fromJson(Map<String, dynamic> json) {
|
||||
if (json.isEmpty || json['data'] == null) return null;
|
||||
return Metadata(
|
||||
data: json['data'],
|
||||
version: json['version'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'data': data,
|
||||
'version': version,
|
||||
};
|
||||
}
|
||||
|
||||
static Metadata? fromEncodedJson(String? encodedJson) {
|
||||
if (encodedJson == null) return null;
|
||||
return Metadata.fromJson(jsonDecode(encodedJson));
|
||||
}
|
||||
|
||||
String toEncodedJson() {
|
||||
return jsonEncode(toJson());
|
||||
}
|
||||
}
|
||||
|
||||
class ApiFileItem {
|
||||
final int fileID;
|
||||
final int ownerID;
|
||||
final Uint8List? thumnailDecryptionHeader;
|
||||
final Uint8List? fileDecryptionHeader;
|
||||
final Metadata? metadata;
|
||||
final Metadata? privMagicMetadata;
|
||||
final Metadata? pubMagicMetadata;
|
||||
final Info? info;
|
||||
|
||||
ApiFileItem({
|
||||
required this.fileID,
|
||||
required this.ownerID,
|
||||
this.thumnailDecryptionHeader,
|
||||
this.fileDecryptionHeader,
|
||||
this.metadata,
|
||||
this.privMagicMetadata,
|
||||
this.pubMagicMetadata,
|
||||
this.info,
|
||||
});
|
||||
|
||||
factory ApiFileItem.deleted(int fileID, int ownerID) {
|
||||
return ApiFileItem(
|
||||
fileID: fileID,
|
||||
ownerID: ownerID,
|
||||
);
|
||||
}
|
||||
|
||||
List<Object?> filesMetadataRowValues() {
|
||||
return [
|
||||
fileID,
|
||||
metadata?.toEncodedJson(),
|
||||
privMagicMetadata?.toEncodedJson(),
|
||||
pubMagicMetadata?.toEncodedJson(),
|
||||
info?.toEncodedJson(),
|
||||
];
|
||||
}
|
||||
|
||||
RemoteAsset toRemoteAsset() {
|
||||
return RemoteAsset.fromMetadata(
|
||||
id: fileID,
|
||||
ownerID: ownerID,
|
||||
thumbHeader: thumnailDecryptionHeader!,
|
||||
fileHeader: fileDecryptionHeader!,
|
||||
metadata: metadata!,
|
||||
privateMetadata: privMagicMetadata,
|
||||
publicMetadata: pubMagicMetadata,
|
||||
info: info,
|
||||
);
|
||||
}
|
||||
|
||||
String get title =>
|
||||
pubMagicMetadata?.data['editedName'] ?? metadata?.data['title'] ?? "";
|
||||
|
||||
String get nonEditedTitle {
|
||||
return metadata?.data['title'] ?? "";
|
||||
}
|
||||
|
||||
String? get localID => metadata?.data['localID'];
|
||||
|
||||
String? get deviceFolder => metadata?.data['deviceFolder'];
|
||||
|
||||
int get creationTime =>
|
||||
pubMagicMetadata?.data['editedTime'] ??
|
||||
metadata?.data['creationTime'] ??
|
||||
0;
|
||||
|
||||
int get modificationTime =>
|
||||
metadata?.data['modificationTime'] ?? creationTime;
|
||||
|
||||
// note: during remote to local sync, older live photo hash format from desktop
|
||||
// is already converted to the new format
|
||||
String? get hash => metadata?.data['hash'];
|
||||
|
||||
int get fileSize => info?.fileSize ?? -1;
|
||||
}
|
||||
|
||||
class DiffItem {
|
||||
final int collectionID;
|
||||
final bool isDeleted;
|
||||
final Uint8List? encFileKey;
|
||||
final Uint8List? encFileKeyNonce;
|
||||
final int updatedAt;
|
||||
final int? createdAt;
|
||||
final ApiFileItem fileItem;
|
||||
final TrashTime? trashTime;
|
||||
|
||||
DiffItem({
|
||||
required this.collectionID,
|
||||
required this.isDeleted,
|
||||
required this.updatedAt,
|
||||
required this.fileItem,
|
||||
this.createdAt,
|
||||
this.encFileKey,
|
||||
this.encFileKeyNonce,
|
||||
this.trashTime,
|
||||
});
|
||||
int get fileID => fileItem.fileID;
|
||||
|
||||
List<Object?> collectionFileRowValues() {
|
||||
return [
|
||||
collectionID,
|
||||
fileID,
|
||||
encFileKey,
|
||||
encFileKeyNonce,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
];
|
||||
}
|
||||
|
||||
List<Object?> trashRowValues() {
|
||||
return [
|
||||
fileID,
|
||||
fileItem.ownerID,
|
||||
collectionID,
|
||||
encFileKey,
|
||||
encFileKeyNonce,
|
||||
fileItem.fileDecryptionHeader,
|
||||
fileItem.thumnailDecryptionHeader,
|
||||
fileItem.metadata?.toEncodedJson(),
|
||||
fileItem.privMagicMetadata?.toEncodedJson(),
|
||||
fileItem.pubMagicMetadata?.toEncodedJson(),
|
||||
fileItem.info?.toEncodedJson(),
|
||||
trashTime!.createdAt,
|
||||
trashTime!.updatedAt,
|
||||
trashTime!.deleteBy,
|
||||
];
|
||||
}
|
||||
}
|
||||
21
mobile/apps/photos/lib/models/api/diff/trash_time.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
class TrashTime {
|
||||
int createdAt;
|
||||
int updatedAt;
|
||||
int deleteBy;
|
||||
TrashTime({
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.deleteBy,
|
||||
});
|
||||
TrashTime.fromMap(Map<String, dynamic> map)
|
||||
: createdAt = map["createdAt"] as int,
|
||||
updatedAt = map["updatedAt"] as int,
|
||||
deleteBy = map["deleteBy"] as int;
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
"createdAt": createdAt,
|
||||
"updatedAt": updatedAt,
|
||||
"deleteBy": deleteBy,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import "dart:convert";
|
||||
import 'dart:core';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -5,6 +6,7 @@ import "package:photos/core/configuration.dart";
|
||||
import "package:photos/extensions/user_extension.dart";
|
||||
import "package:photos/models/api/collection/public_url.dart";
|
||||
import "package:photos/models/api/collection/user.dart";
|
||||
import "package:photos/models/collection/collection_old.dart";
|
||||
import "package:photos/models/metadata/collection_magic.dart";
|
||||
import "package:photos/models/metadata/common_keys.dart";
|
||||
|
||||
@@ -12,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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
252
mobile/apps/photos/lib/models/collection/collection_old.dart
Normal file
@@ -0,0 +1,252 @@
|
||||
import "package:photos/models/api/collection/public_url.dart";
|
||||
import "package:photos/models/api/collection/user.dart";
|
||||
import "package:photos/models/collection/collection.dart";
|
||||
import "package:photos/models/metadata/collection_magic.dart";
|
||||
import "package:photos/models/metadata/common_keys.dart";
|
||||
|
||||
class CollectionV2 {
|
||||
final int id;
|
||||
final User owner;
|
||||
final String encryptedKey;
|
||||
final String? keyDecryptionNonce;
|
||||
@Deprecated("Use collectionName instead")
|
||||
String? name;
|
||||
|
||||
// encryptedName & nameDecryptionNonce will be null for collections
|
||||
// created before we started encrypting collection name
|
||||
final String? encryptedName;
|
||||
final String? nameDecryptionNonce;
|
||||
final CollectionType type;
|
||||
final CollectionAttributes attributes;
|
||||
final List<User> sharees;
|
||||
final List<PublicURL> publicURLs;
|
||||
final int updationTime;
|
||||
final bool isDeleted;
|
||||
|
||||
// In early days before public launch, we used to store collection name
|
||||
// un-encrypted. decryptName will be value either decrypted value for
|
||||
// encryptedName or name itself.
|
||||
String? decryptedName;
|
||||
|
||||
// decryptedPath will be null for collections now owned by user, deleted
|
||||
// collections, && collections which don't have a path. The path is used
|
||||
// to map local on-device album on mobile to remote collection on ente.
|
||||
String? decryptedPath;
|
||||
String? mMdEncodedJson;
|
||||
String? mMdPubEncodedJson;
|
||||
String? sharedMmdJson;
|
||||
int mMdVersion = 0;
|
||||
int mMbPubVersion = 0;
|
||||
int sharedMmdVersion = 0;
|
||||
CollectionMagicMetadata? _mmd;
|
||||
CollectionPubMagicMetadata? _pubMmd;
|
||||
ShareeMagicMetadata? _sharedMmd;
|
||||
|
||||
CollectionMagicMetadata get magicMetadata =>
|
||||
_mmd ?? CollectionMagicMetadata.fromEncodedJson(mMdEncodedJson ?? '{}');
|
||||
|
||||
CollectionPubMagicMetadata get pubMagicMetadata =>
|
||||
_pubMmd ??
|
||||
CollectionPubMagicMetadata.fromEncodedJson(mMdPubEncodedJson ?? '{}');
|
||||
|
||||
ShareeMagicMetadata get sharedMagicMetadata =>
|
||||
_sharedMmd ?? ShareeMagicMetadata.fromEncodedJson(sharedMmdJson ?? '{}');
|
||||
|
||||
set magicMetadata(CollectionMagicMetadata? val) => _mmd = val;
|
||||
|
||||
set pubMagicMetadata(CollectionPubMagicMetadata? val) => _pubMmd = val;
|
||||
|
||||
set sharedMagicMetadata(ShareeMagicMetadata? val) => _sharedMmd = val;
|
||||
|
||||
String get displayName => decryptedName ?? name ?? "Unnamed Album";
|
||||
|
||||
// set the value for both name and decryptedName till we finish migration
|
||||
void setName(String newName) {
|
||||
name = newName;
|
||||
decryptedName = newName;
|
||||
}
|
||||
|
||||
CollectionV2(
|
||||
this.id,
|
||||
this.owner,
|
||||
this.encryptedKey,
|
||||
this.keyDecryptionNonce,
|
||||
this.name,
|
||||
this.encryptedName,
|
||||
this.nameDecryptionNonce,
|
||||
this.type,
|
||||
this.attributes,
|
||||
this.sharees,
|
||||
this.publicURLs,
|
||||
this.updationTime, {
|
||||
this.isDeleted = false,
|
||||
});
|
||||
|
||||
bool isArchived() {
|
||||
return mMdVersion > 0 && magicMetadata.visibility == archiveVisibility;
|
||||
}
|
||||
|
||||
bool hasShareeArchived() {
|
||||
return sharedMmdVersion > 0 &&
|
||||
sharedMagicMetadata.visibility == archiveVisibility;
|
||||
}
|
||||
|
||||
// hasLink returns true if there's any link attached to the collection
|
||||
// including expired links
|
||||
bool get hasLink => publicURLs.isNotEmpty;
|
||||
|
||||
bool get hasCover => (pubMagicMetadata.coverID ?? 0) > 0;
|
||||
|
||||
// hasSharees returns true if the collection is shared with other ente users
|
||||
bool get hasSharees => sharees.isNotEmpty;
|
||||
|
||||
bool get isPinned => (magicMetadata.order ?? 0) != 0;
|
||||
|
||||
bool isHidden() {
|
||||
if (isDefaultHidden()) {
|
||||
return true;
|
||||
}
|
||||
return mMdVersion > 0 && (magicMetadata.visibility == hiddenVisibility);
|
||||
}
|
||||
|
||||
bool isDefaultHidden() {
|
||||
return (magicMetadata.subType ?? 0) == subTypeDefaultHidden;
|
||||
}
|
||||
|
||||
bool isQuickLinkCollection() {
|
||||
return (magicMetadata.subType ?? 0) == subTypeSharedFilesCollection &&
|
||||
!hasSharees;
|
||||
}
|
||||
|
||||
List<User> getSharees() {
|
||||
return sharees;
|
||||
}
|
||||
|
||||
bool isOwner(int userID) {
|
||||
return (owner.id ?? -100) == userID;
|
||||
}
|
||||
|
||||
bool isDownloadEnabledForPublicLink() {
|
||||
if (publicURLs.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
return publicURLs.first.enableDownload;
|
||||
}
|
||||
|
||||
bool isCollectEnabledForPublicLink() {
|
||||
if (publicURLs.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
return publicURLs.first.enableCollect;
|
||||
}
|
||||
|
||||
bool get isJoinEnabled {
|
||||
if (publicURLs.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
return publicURLs.first.enableJoin;
|
||||
}
|
||||
|
||||
CollectionParticipantRole getRole(int userID) {
|
||||
if (isOwner(userID)) {
|
||||
return CollectionParticipantRole.owner;
|
||||
}
|
||||
if (sharees.isEmpty) {
|
||||
return CollectionParticipantRole.unknown;
|
||||
}
|
||||
for (final User u in sharees) {
|
||||
if (u.id == userID) {
|
||||
if (u.isViewer) {
|
||||
return CollectionParticipantRole.viewer;
|
||||
} else if (u.isCollaborator) {
|
||||
return CollectionParticipantRole.collaborator;
|
||||
}
|
||||
}
|
||||
}
|
||||
return CollectionParticipantRole.unknown;
|
||||
}
|
||||
|
||||
// canLinkToDevicePath returns true if the collection can be linked to local
|
||||
// device album based on path. The path is nothing but the name of the device
|
||||
// album.
|
||||
bool canLinkToDevicePath(int userID) {
|
||||
return isOwner(userID) && !isDeleted && attributes.encryptedPath != null;
|
||||
}
|
||||
|
||||
void updateSharees(List<User> newSharees) {
|
||||
sharees.clear();
|
||||
sharees.addAll(newSharees);
|
||||
}
|
||||
|
||||
CollectionV2 copyWith({
|
||||
int? id,
|
||||
User? owner,
|
||||
String? encryptedKey,
|
||||
String? keyDecryptionNonce,
|
||||
String? name,
|
||||
String? encryptedName,
|
||||
String? nameDecryptionNonce,
|
||||
CollectionType? type,
|
||||
CollectionAttributes? attributes,
|
||||
List<User>? sharees,
|
||||
List<PublicURL>? publicURLs,
|
||||
int? updationTime,
|
||||
bool? isDeleted,
|
||||
String? mMdEncodedJson,
|
||||
int? mMdVersion,
|
||||
String? decryptedName,
|
||||
String? decryptedPath,
|
||||
}) {
|
||||
final CollectionV2 result = CollectionV2(
|
||||
id ?? this.id,
|
||||
owner ?? this.owner,
|
||||
encryptedKey ?? this.encryptedKey,
|
||||
keyDecryptionNonce ?? this.keyDecryptionNonce,
|
||||
name ?? this.name,
|
||||
encryptedName ?? this.encryptedName,
|
||||
nameDecryptionNonce ?? this.nameDecryptionNonce,
|
||||
type ?? this.type,
|
||||
attributes ?? this.attributes,
|
||||
sharees ?? this.sharees,
|
||||
publicURLs ?? this.publicURLs,
|
||||
updationTime ?? this.updationTime,
|
||||
isDeleted: isDeleted ?? this.isDeleted,
|
||||
);
|
||||
result.mMdVersion = mMdVersion ?? this.mMdVersion;
|
||||
result.mMdEncodedJson = mMdEncodedJson ?? this.mMdEncodedJson;
|
||||
result.decryptedName = decryptedName ?? this.decryptedName;
|
||||
result.decryptedPath = decryptedPath ?? this.decryptedPath;
|
||||
result.mMbPubVersion = mMbPubVersion;
|
||||
result.mMdPubEncodedJson = mMdPubEncodedJson;
|
||||
result.sharedMmdVersion = sharedMmdVersion;
|
||||
result.sharedMmdJson = sharedMmdJson;
|
||||
return result;
|
||||
}
|
||||
|
||||
static CollectionV2 fromMap(Map<String, dynamic> map) {
|
||||
final sharees = (map['sharees'] == null || map['sharees'].length == 0)
|
||||
? <User>[]
|
||||
: List<User>.from(map['sharees'].map((x) => User.fromMap(x)));
|
||||
final publicURLs =
|
||||
(map['publicURLs'] == null || map['publicURLs'].length == 0)
|
||||
? <PublicURL>[]
|
||||
: List<PublicURL>.from(
|
||||
map['publicURLs'].map((x) => PublicURL.fromMap(x)),
|
||||
);
|
||||
return CollectionV2(
|
||||
map['id'],
|
||||
User.fromMap(map['owner']),
|
||||
map['encryptedKey'],
|
||||
map['keyDecryptionNonce'],
|
||||
map['name'],
|
||||
map['encryptedName'],
|
||||
map['nameDecryptionNonce'],
|
||||
typeFromString(map['type']),
|
||||
CollectionAttributes.fromMap(map['attributes']),
|
||||
sharees,
|
||||
publicURLs,
|
||||
map['updationTime'],
|
||||
isDeleted: map['isDeleted'] ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||