Compare commits
186 Commits
java_test
...
photos-v1.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49c90a802a | ||
|
|
8b2db5e576 | ||
|
|
b5d4839e04 | ||
|
|
ac57097eb4 | ||
|
|
4e08e38bf6 | ||
|
|
a7d3cf4178 | ||
|
|
c63dfc36e9 | ||
|
|
2985503254 | ||
|
|
9be023d68a | ||
|
|
6a6e1b3c47 | ||
|
|
7516363715 | ||
|
|
2b76b71db8 | ||
|
|
c32a70fb25 | ||
|
|
4098c1a072 | ||
|
|
972be1f41e | ||
|
|
3acb2136d0 | ||
|
|
eba729625f | ||
|
|
a477742cd0 | ||
|
|
c974bde11c | ||
|
|
ecc654bae0 | ||
|
|
201ef88305 | ||
|
|
742035d7cc | ||
|
|
8f29d5aa19 | ||
|
|
8a4e76fb6f | ||
|
|
c03eaf83aa | ||
|
|
378878538d | ||
|
|
01c3d6b105 | ||
|
|
c6f5c68f1e | ||
|
|
d0c8925ff3 | ||
|
|
d6c84421ce | ||
|
|
0d1f20f9e2 | ||
|
|
c55447a08f | ||
|
|
98d56e8fa4 | ||
|
|
f244c94ebf | ||
|
|
88f2b88f4d | ||
|
|
db1fef40db | ||
|
|
1fd29cdd13 | ||
|
|
947d294afe | ||
|
|
515715660e | ||
|
|
324221171d | ||
|
|
f5f2ff1b2c | ||
|
|
244d41621c | ||
|
|
91b6a08a35 | ||
|
|
770a311da5 | ||
|
|
db76dee639 | ||
|
|
20ce760e85 | ||
|
|
df1bfbe839 | ||
|
|
27d72eb821 | ||
|
|
98786c5824 | ||
|
|
d38a09c3f0 | ||
|
|
91785d8c90 | ||
|
|
b1f28e3f2e | ||
|
|
c155bdd058 | ||
|
|
a859f28e2c | ||
|
|
8d75528aa5 | ||
|
|
7f43c11985 | ||
|
|
aadda7e3f6 | ||
|
|
210c18d244 | ||
|
|
6636849838 | ||
|
|
5500315351 | ||
|
|
562292e642 | ||
|
|
4aa80edbcf | ||
|
|
9524a639cd | ||
|
|
b8eb793c16 | ||
|
|
4b514f1e1a | ||
|
|
bee2bb9621 | ||
|
|
772121c22e | ||
|
|
3c49ca0f6e | ||
|
|
f2e51893ad | ||
|
|
c08b78c775 | ||
|
|
233f03355f | ||
|
|
73ab50f113 | ||
|
|
4a2346fe93 | ||
|
|
68b5cce158 | ||
|
|
e907a9e8cb | ||
|
|
92a40afca2 | ||
|
|
0c2b38c059 | ||
|
|
19650bcd57 | ||
|
|
2b9ca073ce | ||
|
|
2257087bb2 | ||
|
|
2a5bce2ae4 | ||
|
|
1e0a6eb1ea | ||
|
|
187a729013 | ||
|
|
c98f4dfffd | ||
|
|
4140a0f6fe | ||
|
|
cf4b87dad9 | ||
|
|
3fd0db6a90 | ||
|
|
ac68b99ecf | ||
|
|
82e1a0e358 | ||
|
|
ce1701d211 | ||
|
|
034e789242 | ||
|
|
ccfec4071f | ||
|
|
c4830732fd | ||
|
|
72dc56e41f | ||
|
|
aaed336991 | ||
|
|
0b85dfe7e4 | ||
|
|
68422b172f | ||
|
|
db99dae3e1 | ||
|
|
3717a156d3 | ||
|
|
ca9930e01b | ||
|
|
eb23a4e770 | ||
|
|
e03303e5b3 | ||
|
|
2ad27f1c6e | ||
|
|
202e6a9f7c | ||
|
|
ceaedad327 | ||
|
|
fd963a1c8e | ||
|
|
b40b5bb1ae | ||
|
|
91827626b2 | ||
|
|
42318335ae | ||
|
|
858db62385 | ||
|
|
46e36612d3 | ||
|
|
62cf236e3b | ||
|
|
c2b1ab86f2 | ||
|
|
43adf42281 | ||
|
|
1e2a65281c | ||
|
|
70eb68b13c | ||
|
|
fa86b19307 | ||
|
|
e632dc7771 | ||
|
|
7fa9adb636 | ||
|
|
83f885f158 | ||
|
|
a295f223b6 | ||
|
|
cc64ef8035 | ||
|
|
69dd7b6233 | ||
|
|
bcc9f1be73 | ||
|
|
296b2a2a6c | ||
|
|
6b48c9bc34 | ||
|
|
6a951bcc72 | ||
|
|
38914981a1 | ||
|
|
66f4d5b1a6 | ||
|
|
9ee3781320 | ||
|
|
23dc809589 | ||
|
|
f72c9fa068 | ||
|
|
0f5e30e96b | ||
|
|
35ded7bc59 | ||
|
|
8e3f6e56d2 | ||
|
|
150534aa1a | ||
|
|
2a136ba087 | ||
|
|
3abb479fbf | ||
|
|
7eda60a493 | ||
|
|
bb8c5caa8d | ||
|
|
0384819c01 | ||
|
|
f55973367d | ||
|
|
699794226f | ||
|
|
dee68acfc3 | ||
|
|
0bd5452837 | ||
|
|
e53ddb8b51 | ||
|
|
95d167878e | ||
|
|
653fc47aed | ||
|
|
34325691e7 | ||
|
|
e474114e22 | ||
|
|
80c07d36a9 | ||
|
|
8581742a73 | ||
|
|
042dae8790 | ||
|
|
bc6506cb10 | ||
|
|
f2a26ba391 | ||
|
|
84f5a5ac3d | ||
|
|
a00fc0b1be | ||
|
|
f5347e7436 | ||
|
|
3f1d574d0c | ||
|
|
891b68c0f4 | ||
|
|
f050c6f9d7 | ||
|
|
2de67b619f | ||
|
|
828dde5ca7 | ||
|
|
2526c69896 | ||
|
|
6e64a2067f | ||
|
|
ab4792518f | ||
|
|
d4ae8d63fc | ||
|
|
618753cb1a | ||
|
|
f84bd20bbf | ||
|
|
6ae7aa70d6 | ||
|
|
48757af5d0 | ||
|
|
cd20a98850 | ||
|
|
9ac9e6bd26 | ||
|
|
0b640c9062 | ||
|
|
2d87aba165 | ||
|
|
7dffdfaecf | ||
|
|
a4da7b5555 | ||
|
|
85b766b5d0 | ||
|
|
62f715d3c1 | ||
|
|
e35ae86fa5 | ||
|
|
ea843eba7a | ||
|
|
b845f4d893 | ||
|
|
8ea36acb7a | ||
|
|
279df8ff57 | ||
|
|
d83994c692 | ||
|
|
be506bdad1 |
32
.github/workflows/mobile-daily-internal.yml
vendored
32
.github/workflows/mobile-daily-internal.yml
vendored
@@ -27,6 +27,38 @@ jobs:
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Free up disk space
|
||||
run: |
|
||||
echo "Initial disk usage:"
|
||||
df -h /
|
||||
# Get available space in KB
|
||||
INITIAL=$(df / | awk 'NR==2 {print $4}')
|
||||
|
||||
echo -e "\n=== Removing .NET SDK (~20-25GB) ==="
|
||||
BEFORE=$(df / | awk 'NR==2 {print $4}')
|
||||
START=$(date +%s)
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
END=$(date +%s)
|
||||
AFTER=$(df / | awk 'NR==2 {print $4}')
|
||||
FREED=$(( (AFTER - BEFORE) / 1048576 )) # Convert KB to GB
|
||||
echo "Time: $((END-START))s | Freed: ${FREED}GB"
|
||||
|
||||
echo -e "\n=== Removing cached tools (~5-10GB) ==="
|
||||
BEFORE=$(df / | awk 'NR==2 {print $4}')
|
||||
START=$(date +%s)
|
||||
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
|
||||
END=$(date +%s)
|
||||
AFTER=$(df / | awk 'NR==2 {print $4}')
|
||||
FREED=$(( (AFTER - BEFORE) / 1048576 ))
|
||||
echo "Time: $((END-START))s | Freed: ${FREED}GB"
|
||||
|
||||
echo -e "\n=== Final Summary ==="
|
||||
FINAL=$(df / | awk 'NR==2 {print $4}')
|
||||
TOTAL_FREED=$(( (FINAL - INITIAL) / 1048576 ))
|
||||
echo "Total space freed: ${TOTAL_FREED}GB"
|
||||
echo "Final disk usage:"
|
||||
df -h /
|
||||
|
||||
- name: Setup JDK 17
|
||||
uses: actions/setup-java@v1
|
||||
with:
|
||||
|
||||
38
.github/workflows/mobile-release.yml
vendored
38
.github/workflows/mobile-release.yml
vendored
@@ -28,6 +28,38 @@ jobs:
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Free up disk space
|
||||
run: |
|
||||
echo "Initial disk usage:"
|
||||
df -h /
|
||||
# Get available space in KB
|
||||
INITIAL=$(df / | awk 'NR==2 {print $4}')
|
||||
|
||||
echo -e "\n=== Removing .NET SDK (~20-25GB) ==="
|
||||
BEFORE=$(df / | awk 'NR==2 {print $4}')
|
||||
START=$(date +%s)
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
END=$(date +%s)
|
||||
AFTER=$(df / | awk 'NR==2 {print $4}')
|
||||
FREED=$(( (AFTER - BEFORE) / 1048576 )) # Convert KB to GB
|
||||
echo "Time: $((END-START))s | Freed: ${FREED}GB"
|
||||
|
||||
echo -e "\n=== Removing cached tools (~5-10GB) ==="
|
||||
BEFORE=$(df / | awk 'NR==2 {print $4}')
|
||||
START=$(date +%s)
|
||||
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
|
||||
END=$(date +%s)
|
||||
AFTER=$(df / | awk 'NR==2 {print $4}')
|
||||
FREED=$(( (AFTER - BEFORE) / 1048576 ))
|
||||
echo "Time: $((END-START))s | Freed: ${FREED}GB"
|
||||
|
||||
echo -e "\n=== Final Summary ==="
|
||||
FINAL=$(df / | awk 'NR==2 {print $4}')
|
||||
TOTAL_FREED=$(( (FINAL - INITIAL) / 1048576 ))
|
||||
echo "Total space freed: ${TOTAL_FREED}GB"
|
||||
echo "Final disk usage:"
|
||||
df -h /
|
||||
|
||||
- name: Setup JDK 17
|
||||
uses: actions/setup-java@v1
|
||||
with:
|
||||
@@ -40,6 +72,12 @@ jobs:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
|
||||
- name: Install Flutter Rust Bridge
|
||||
run: cargo install flutter_rust_bridge_codegen
|
||||
|
||||
- name: Generate Rust bindings
|
||||
run: flutter_rust_bridge_codegen generate
|
||||
|
||||
- name: Setup keys
|
||||
uses: timheuer/base64-to-file@v1
|
||||
with:
|
||||
|
||||
@@ -48,7 +48,11 @@ 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -1236,6 +1236,12 @@
|
||||
"title": "Parqet",
|
||||
"slug": "parqet"
|
||||
},
|
||||
{
|
||||
"title": "Parallels",
|
||||
"slug": "parallels",
|
||||
"hex": "#E61E25",
|
||||
"altNames": ["Parallels Desktop", "Parallels VM"]
|
||||
},
|
||||
{
|
||||
"title": "Parsec"
|
||||
},
|
||||
|
||||
4
mobile/apps/auth/assets/custom-icons/icons/parallels.svg
Normal file
4
mobile/apps/auth/assets/custom-icons/icons/parallels.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect x="20" y="10" width="10" height="80" rx="5" fill="#E61E25"/>
|
||||
<rect x="50" y="10" width="10" height="80" rx="5" fill="#E61E25"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 207 B |
@@ -382,7 +382,8 @@ class _HomePageState extends State<HomePage> {
|
||||
final bool shouldShowLockScreen =
|
||||
await LockScreenSettings.instance.shouldShowLockScreen();
|
||||
if (shouldShowLockScreen) {
|
||||
await AppLock.of(context)!.showLockScreen();
|
||||
// Manual lock: do not auto-prompt Touch ID; wait for user tap
|
||||
await AppLock.of(context)!.showManualLockScreen();
|
||||
} else {
|
||||
await showDialogWidget(
|
||||
context: context,
|
||||
|
||||
@@ -7,8 +7,7 @@ import 'package:ente_events/event_bus.dart';
|
||||
import 'package:ente_events/models/signed_in_event.dart';
|
||||
import 'package:ente_events/models/signed_out_event.dart';
|
||||
import 'package:ente_strings/l10n/strings_localizations.dart';
|
||||
import 'package:ente_ui/theme/colors.dart';
|
||||
import 'package:ente_ui/theme/ente_theme_data.dart';
|
||||
import "package:ente_ui/theme/ente_theme_data.dart";
|
||||
import 'package:ente_ui/utils/window_listener_service.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import "package:flutter/material.dart";
|
||||
@@ -87,37 +86,14 @@ class _AppState extends State<App>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final schemes = ColorSchemeBuilder.fromCustomColors(
|
||||
primary700: const Color(0xFF1565C0), // Dark blue
|
||||
primary500: const Color(0xFF2196F3), // Material blue
|
||||
primary400: const Color(0xFF42A5F5), // Light blue
|
||||
primary300: const Color(0xFF90CAF9), // Very light blue
|
||||
iconButtonColor: const Color(0xFF1976D2), // Custom icon color
|
||||
gradientButtonBgColors: const [
|
||||
Color(0xFF1565C0),
|
||||
Color(0xFF2196F3),
|
||||
Color(0xFF42A5F5),
|
||||
],
|
||||
);
|
||||
|
||||
final lightTheme = createAppThemeData(
|
||||
brightness: Brightness.light,
|
||||
colorScheme: schemes.light,
|
||||
);
|
||||
|
||||
final darkTheme = createAppThemeData(
|
||||
brightness: Brightness.dark,
|
||||
colorScheme: schemes.dark,
|
||||
);
|
||||
|
||||
Widget buildApp() {
|
||||
if (Platform.isAndroid ||
|
||||
Platform.isWindows ||
|
||||
Platform.isLinux ||
|
||||
kDebugMode) {
|
||||
return AdaptiveTheme(
|
||||
light: lightTheme,
|
||||
dark: darkTheme,
|
||||
light: lightThemeData,
|
||||
dark: darkThemeData,
|
||||
initial: AdaptiveThemeMode.system,
|
||||
builder: (lightTheme, dartTheme) => MaterialApp(
|
||||
title: "ente",
|
||||
@@ -142,8 +118,8 @@ class _AppState extends State<App>
|
||||
return MaterialApp(
|
||||
title: "ente",
|
||||
themeMode: ThemeMode.system,
|
||||
theme: lightTheme,
|
||||
darkTheme: darkTheme,
|
||||
theme: lightThemeData,
|
||||
darkTheme: darkThemeData,
|
||||
debugShowCheckedModeBanner: false,
|
||||
locale: locale,
|
||||
supportedLocales: appSupportedLocales,
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:ente_lock_screen/ui/lock_screen.dart';
|
||||
import 'package:ente_logging/logging.dart';
|
||||
import 'package:ente_network/network.dart';
|
||||
import "package:ente_strings/l10n/strings_localizations.dart";
|
||||
import "package:ente_ui/theme/ente_theme_data.dart";
|
||||
import "package:ente_ui/theme/theme_config.dart";
|
||||
import 'package:ente_ui/utils/window_listener_service.dart';
|
||||
import 'package:ente_utils/platform_util.dart';
|
||||
@@ -103,6 +104,8 @@ Future<void> _runInForeground() async {
|
||||
lockScreen: LockScreen(Configuration.instance),
|
||||
enabled: await LockScreenSettings.instance.shouldShowLockScreen(),
|
||||
locale: locale,
|
||||
lightTheme: lightThemeData,
|
||||
darkTheme: darkThemeData,
|
||||
savedThemeMode: savedThemeMode,
|
||||
supportedLocales: appSupportedLocales,
|
||||
localizationsDelegates: const [
|
||||
|
||||
204
mobile/apps/photos/CLAUDE.md
Normal file
204
mobile/apps/photos/CLAUDE.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# 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,4 +1,4 @@
|
||||
ente es una aplicación simple para hacer copias de seguridad y compartir tus fotos y videos.
|
||||
ente es una aplicación simple para hacer copias de seguridad y compartir tus fotos y vídeos.
|
||||
|
||||
Si has estado buscando una alternativa a Google Photos que sea amigable con la privacidad, has llegado al lugar correcto. Con Ente, se almacenan cifradas de extremo a extremo (e2ee). Esto significa que solo tú puedes verlas.
|
||||
|
||||
|
||||
@@ -6,23 +6,23 @@ Kami menyediakan app untuk Android, iOS, web, serta desktop, dan fotomu akan ter
|
||||
|
||||
ente juga dapat memudahkan kamu untuk membagikan album ke orang tersayang, meski mereka tidak punya akun ente. Kamu dapat membagikan link berbagi publik, di mana mereka bisa melihat album kamu dan berkolaborasi dengan menambahkan foto, tanpa akun atau app.
|
||||
|
||||
Data terenkripsi kamu tersimpan di 3 lokasi berbeda, termasuk di salah satu tempat pengungsian di Paris. We take posterity seriously and make it easy to ensure that your memories outlive you.
|
||||
Data terenkripsi kamu tersimpan di 3 lokasi berbeda, termasuk di salah satu tempat pengungsian di Paris. Kami menanggapi keturunan anda dengan serius dan memudahkan untuk memastikan kenangan anda tetap ada setelah anda.
|
||||
|
||||
Kami ingin membuat app foto yang paling aman sepanjang masa–jadi, bergabunglah dengan kami!
|
||||
|
||||
FITUR
|
||||
- Pencadangan kualitas asli, karena setiap piksel berarti
|
||||
- Paket keluarga, sehingga kamu bisa bagikan kuota penyimpananmu dengan keluarga
|
||||
- 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 kolaboratif, sehingga anda dapat mengumpulkan foto bersama setelah sebuah perjalanan
|
||||
- Folder bersama, jika anda ingin pasangan anda menikmati hasil jepretan "Kamera" anda
|
||||
- Link album, yang bisa dilindungi dengan sandi
|
||||
Kemampuan untuk membebaskan kapasitas, dengan menghilangkan files yang sudah di back-up dengan aman
|
||||
- Human support, because you're worth it
|
||||
- Descriptions, so you can caption your memories and find them easily
|
||||
- Dukungan manusia, karena anda layak mendapatkannya
|
||||
- Deskripsi, sehingga anda dapat memberi keterangan pada memori anda dan menemukannya dengan mudah
|
||||
- Editor gambar, untuk menyempurnakan fotomu
|
||||
- Favorite, hide and relive your memories, for they are precious
|
||||
- Favoritkan, sembunyikan, dan kenang kembali memori anda, karena itu sangat berharga
|
||||
- Pengimporan mudah dari Google, Apple, hard drive-mu, dan lainnya
|
||||
- Dark theme, because your photos look good in it
|
||||
- Tema gelap, karena foto anda terlihat bagus di dalamnya
|
||||
- Autentikasi dua atau tiga faktor dan autentikasi biometrik
|
||||
- dan BANYAK LAGI!
|
||||
|
||||
|
||||
@@ -6,20 +6,20 @@ Kami menyediakan app untuk semua platform, dan fotomu akan tersinkron di semua p
|
||||
|
||||
Ente juga memudahkan kamu untuk membagikan album ke kerabatmu. Kamu bisa membagikannya secara langsung ke pengguna Ente lain, terenkripsi ujung ke ujung; atau dengan link yang dapat dilihat publik.
|
||||
|
||||
Data terenkripsi kamu tersimpan di berbagai lokasi, termasuk di salah satu tempat pengungsian di Paris. We take posterity seriously and make it easy to ensure that your memories outlive you.
|
||||
Data terenkripsi kamu tersimpan di berbagai lokasi, termasuk di salah satu tempat pengungsian di Paris. Kami menanggapi keturunan anda dengan serius dan memudahkan untuk memastikan kenangan anda tetap ada setelah anda.
|
||||
|
||||
Kami ingin membuat app foto yang paling aman sepanjang masa–jadi, bergabunglah dengan kami!
|
||||
|
||||
FITUR
|
||||
- Pencadangan kualitas asli, karena setiap piksel berarti
|
||||
- Paket keluarga, sehingga kamu bisa bagikan kuota penyimpananmu dengan keluarga
|
||||
- Shared folders, in case you want your partner to enjoy your "Camera" clicks
|
||||
- Folder bersama, jika anda ingin pasangan anda menikmati hasil jepretan "Kamera" anda
|
||||
- Link album, yang bisa dilindungi dengan sandi dan diatur waktu kedaluwarsanya
|
||||
- Ability to free up space, by removing files that have been safely backed up
|
||||
- Kemampuan untuk mengosongkan ruang, dengan menghapus file yang telah dicadangkan dengan aman
|
||||
- Editor gambar, untuk menyempurnakan fotomu
|
||||
- Favorite, hide and relive your memories, for they are precious
|
||||
- One-click import from all major storage providers
|
||||
- Dark theme, because your photos look good in it
|
||||
Favoritkan, sembunyikan, dan kenang kembali memori anda, karena itu sangat berharga
|
||||
Impor sekali klik dari semua penyedia penyimpanan utama
|
||||
Tema gelap, karena foto anda terlihat bagus di dalamnya
|
||||
- Autentikasi dua atau tiga faktor dan autentikasi biometrik
|
||||
- dan BANYAK LAGI!
|
||||
|
||||
@@ -27,7 +27,7 @@ HARGA
|
||||
Kami tidak menyediakan paket yang gratis seumur hidup, karena penting bagi kami untuk tetap berdiri dan bertahan hingga masa depan. Namun, kami menyediakan paket yang terjangkau, yang bisa kamu bagikan dengan keluargamu. Kamu bisa menemukan informasi lebih lanjut di ente.io.
|
||||
|
||||
DUKUNGAN
|
||||
We take pride in offering human support. Jika kamu adalah pelanggan berbayar, kamu bisa menghubungi team@ente.io dan menunggu balasan dari tim kami dalam 24 jam.
|
||||
Kami bangga dapat menawarkan dukungan manusia. Jika kamu adalah pelanggan berbayar, kamu bisa menghubungi team@ente.io dan menunggu balasan dari tim kami dalam 24 jam.
|
||||
|
||||
KETENTUAN
|
||||
https://ente.io/terms
|
||||
|
||||
@@ -6,20 +6,20 @@ Kami menyediakan app untuk Android, iOS, Web, serta Desktop, dan fotomu akan ter
|
||||
|
||||
Ente juga memudahkan kamu untuk membagikan album ke kerabatmu. Kamu bisa membagikannya secara langsung ke pengguna Ente lain, terenkripsi ujung ke ujung; atau dengan link yang dapat dilihat publik.
|
||||
|
||||
Data terenkripsi kamu tersimpan di berbagai lokasi, termasuk di salah satu tempat pengungsian di Paris. We take posterity seriously and make it easy to ensure that your memories outlive you.
|
||||
Data terenkripsi kamu tersimpan di berbagai lokasi, termasuk di salah satu tempat pengungsian di Paris. Kami menanggapi keturunan anda dengan serius dan memudahkan untuk memastikan kenangan anda tetap ada setelah anda.
|
||||
|
||||
Kami ingin membuat app foto yang paling aman sepanjang masa–jadi, bergabunglah dengan kami!
|
||||
|
||||
✨ FITUR
|
||||
- Pencadangan kualitas asli, karena setiap piksel berarti
|
||||
- Paket keluarga, sehingga kamu bisa bagikan kuota penyimpananmu dengan keluarga
|
||||
- Shared folders, in case you want your partner to enjoy your "Camera" clicks
|
||||
- Folder bersama, jika anda ingin pasangan anda menikmati hasil jepretan "Kamera" anda
|
||||
- Link album, yang bisa dilindungi dengan sandi dan diatur waktu kedaluwarsanya
|
||||
- Ability to free up space, by removing files that have been safely backed up
|
||||
- Kemampuan untuk mengosongkan ruang, dengan menghapus file yang telah dicadangkan dengan aman
|
||||
- Editor gambar, untuk menyempurnakan fotomu
|
||||
- Favorite, hide and relive your memories, for they are precious
|
||||
- Favoritkan, sembunyikan, dan kenang kembali memori anda, karena itu sangat berharga
|
||||
- Pengimporan mudah dari Google, Apple, hard drive-mu, dan lainnya
|
||||
- Dark theme, because your photos look good in it
|
||||
- Tema gelap, karena foto anda terlihat bagus di dalamnya
|
||||
- Autentikasi dua atau tiga faktor dan autentikasi biometrik
|
||||
- dan BANYAK LAGI!
|
||||
|
||||
@@ -27,4 +27,4 @@ Kami ingin membuat app foto yang paling aman sepanjang masa–jadi, bergabunglah
|
||||
Kami tidak menyediakan paket yang gratis seumur hidup, karena penting bagi kami untuk tetap berdiri dan bertahan hingga masa depan. Namun, kami menyediakan paket yang terjangkau, yang bisa kamu bagikan dengan keluargamu. Kamu bisa menemukan informasi lebih lanjut di ente.io.
|
||||
|
||||
🙋 DUKUNGAN
|
||||
We take pride in offering human support. Jika kamu adalah pelanggan berbayar, kamu bisa menghubungi team@ente.io dan menunggu balasan dari tim kami dalam 24 jam.
|
||||
Kami bangga dapat menawarkan dukungan manusia. Jika kamu adalah pelanggan berbayar, kamu bisa menghubungi team@ente.io dan menunggu balasan dari tim kami dalam 24 jam.
|
||||
@@ -181,6 +181,8 @@ PODS:
|
||||
- PromisesObjC (2.4.0)
|
||||
- receive_sharing_intent (1.8.1):
|
||||
- Flutter
|
||||
- rive_common (0.0.1):
|
||||
- Flutter
|
||||
- rust_lib_photos (0.0.1):
|
||||
- Flutter
|
||||
- SDWebImage (5.21.1):
|
||||
@@ -291,6 +293,7 @@ DEPENDENCIES:
|
||||
- photo_manager (from `.symlinks/plugins/photo_manager/ios`)
|
||||
- privacy_screen (from `.symlinks/plugins/privacy_screen/ios`)
|
||||
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
|
||||
- rive_common (from `.symlinks/plugins/rive_common/ios`)
|
||||
- rust_lib_photos (from `.symlinks/plugins/rust_lib_photos/ios`)
|
||||
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
@@ -309,7 +312,7 @@ DEPENDENCIES:
|
||||
- workmanager (from `.symlinks/plugins/workmanager/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
https://github.com/ente-io/ffmpeg-kit-custom-repo-ios:
|
||||
https://github.com/ente-io/ffmpeg-kit-custom-repo-ios.git:
|
||||
- ffmpeg_kit_custom
|
||||
trunk:
|
||||
- Firebase
|
||||
@@ -418,6 +421,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/privacy_screen/ios"
|
||||
receive_sharing_intent:
|
||||
:path: ".symlinks/plugins/receive_sharing_intent/ios"
|
||||
rive_common:
|
||||
:path: ".symlinks/plugins/rive_common/ios"
|
||||
rust_lib_photos:
|
||||
:path: ".symlinks/plugins/rust_lib_photos/ios"
|
||||
sentry_flutter:
|
||||
@@ -452,84 +457,85 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/workmanager/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7
|
||||
battery_info: 83f3aae7be2fccefab1d2bf06b8aa96f11c8bcdd
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
|
||||
dart_ui_isolate: 46f6714abe6891313267153ef6f9748d8ecfcab1
|
||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||
emoji_picker_flutter: ed468d9746c21711e66b2788880519a9de5de211
|
||||
app_links: f3e17e4ee5e357b39d8b95290a9b2c299fca71c6
|
||||
battery_info: b6c551049266af31556b93c9d9b9452cfec0219f
|
||||
connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d
|
||||
cupertino_http: 947a233f40cfea55167a49f2facc18434ea117ba
|
||||
dart_ui_isolate: d5bcda83ca4b04f129d70eb90110b7a567aece14
|
||||
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
|
||||
emoji_picker_flutter: fe2e6151c5b548e975d546e6eeb567daf0962a58
|
||||
ffmpeg_kit_custom: 682b4f2f1ff1f8abae5a92f6c3540f2441d5be99
|
||||
ffmpeg_kit_flutter: 915b345acc97d4142e8a9a8549d177ff10f043f5
|
||||
file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6
|
||||
ffmpeg_kit_flutter: 9dce4803991478c78c6fb9f972703301101095fe
|
||||
file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
|
||||
Firebase: d99ac19b909cd2c548339c2241ecd0d1599ab02e
|
||||
firebase_core: ece862f94b2bc72ee0edbeec7ab5c7cb09fe1ab5
|
||||
firebase_messaging: e1a5fae495603115be1d0183bc849da748734e2b
|
||||
firebase_core: cf4d42a8ac915e51c0c2dc103442f3036d941a2d
|
||||
firebase_messaging: fee490327c1aae28a0da1e65fca856547deca493
|
||||
FirebaseCore: efb3893e5b94f32b86e331e3bd6dadf18b66568e
|
||||
FirebaseCoreInternal: 9afa45b1159304c963da48addb78275ef701c6b4
|
||||
FirebaseInstallations: 317270fec08a5d418fdbc8429282238cab3ac843
|
||||
FirebaseMessaging: 3b26e2cee503815e01c3701236b020aa9b576f09
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
flutter_email_sender: aa1e9772696691d02cd91fea829856c11efb8e58
|
||||
flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1
|
||||
flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99
|
||||
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
|
||||
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
||||
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
||||
flutter_sodium: 7e4621538491834eba53bd524547854bcbbd6987
|
||||
flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544
|
||||
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
|
||||
flutter_email_sender: e03bdda7637bcd3539bfe718fddd980e9508efaa
|
||||
flutter_image_compress_common: ec1d45c362c9d30a3f6a0426c297f47c52007e3e
|
||||
flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4
|
||||
flutter_local_notifications: ff50f8405aaa0ccdc7dcfb9022ca192e8ad9688f
|
||||
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
|
||||
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
|
||||
flutter_sodium: a00383520fc689c688b66fd3092984174712493e
|
||||
flutter_timezone: ac3da59ac941ff1c98a2e1f0293420e020120282
|
||||
fluttertoast: 21eecd6935e7064cc1fcb733a4c5a428f3f24f0f
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
|
||||
in_app_purchase_storekit: d1a48cb0f8b29dbf5f85f782f5dd79b21b90a5e6
|
||||
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
|
||||
launcher_icon_switcher: 84c218d233505aa7d8655d8fa61a3ba802c022da
|
||||
home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57
|
||||
in_app_purchase_storekit: a1ce04056e23eecc666b086040239da7619cd783
|
||||
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
|
||||
launcher_icon_switcher: 8e0ad2131a20c51c1dd939896ee32e70cd845b37
|
||||
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
||||
local_auth_ios: f7a1841beef3151d140a967c2e46f30637cdf451
|
||||
local_auth_ios: 5046a18c018dd973247a0564496c8898dbb5adf9
|
||||
Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d
|
||||
maps_launcher: edf829809ba9e894d70e569bab11c16352dedb45
|
||||
media_extension: 671e2567880d96c95c65c9a82ccceed8f2e309fd
|
||||
media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854
|
||||
media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474
|
||||
motion_sensors: 741e702c17467b9569a92165dda8d4d88c6167f1
|
||||
motionphoto: 23e2aeb5c6380112f69468d71f970fa7438e5ed1
|
||||
move_to_background: 7e3467dd2a1d1013e98c9c1cb93fd53cd7ef9d84
|
||||
maps_launcher: 2e5b6a2d664ec6c27f82ffa81b74228d770ab203
|
||||
media_extension: 6618f07abd762cdbfaadf1b0c56a287e820f0c84
|
||||
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
|
||||
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
|
||||
motion_sensors: 03f55b7c637a7e365a0b5f9697a449f9059d5d91
|
||||
motionphoto: 8b65ce50c7d7ff3c767534fc3768b2eed9ac24e4
|
||||
move_to_background: cd3091014529ec7829e342ad2d75c0a11f4378a5
|
||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||
native_video_player: 6809dec117e8997161dbfb42a6f90d6df71a504d
|
||||
objective_c: 89e720c30d716b036faf9c9684022048eee1eee2
|
||||
onnxruntime: f9b296392c96c42882be020a59dbeac6310d81b2
|
||||
native_video_player: 29ab24a926804ac8c4a57eb6d744c7d927c2bc3e
|
||||
objective_c: 77e887b5ba1827970907e10e832eec1683f3431d
|
||||
onnxruntime: e7c2ae44385191eaad5ae64c935a72debaddc997
|
||||
onnxruntime-c: a909204639a1f035f575127ac406f781ac797c9c
|
||||
onnxruntime-objc: b6fab0f1787aa6f7190c2013f03037df4718bd8b
|
||||
open_mail_app: 7314a609e88eed22d53671279e189af7a0ab0f11
|
||||
open_mail_app: 70273c53f768beefdafbe310c3d9086e4da3cb02
|
||||
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
photo_manager: 1d80ae07a89a67dfbcae95953a1e5a24af7c3e62
|
||||
privacy_screen: 3159a541f5d3a31bea916cfd4e58f9dc722b3fd4
|
||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
photo_manager: 81954a1bf804b6e882d0453b3b6bc7fad7b47d3d
|
||||
privacy_screen: 1a131c052ceb3c3659934b003b0d397c2381a24e
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
|
||||
rust_lib_photos: 04d3901908d2972192944083310b65abf410774c
|
||||
receive_sharing_intent: 79c848f5b045674ad60b9fea3bafea59962ad2c1
|
||||
rive_common: 4743dbfd2911c99066547a3c6454681e0fa907df
|
||||
rust_lib_photos: 8813b31af48ff02ca75520cbc81a363a13d51a84
|
||||
SDWebImage: f29024626962457f3470184232766516dee8dfea
|
||||
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380
|
||||
Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854
|
||||
sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684
|
||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
sentry_flutter: 2df8b0aab7e4aba81261c230cbea31c82a62dd1b
|
||||
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||
sqlite3: 1d85290c3321153511f6e900ede7a1608718bbd5
|
||||
sqlite3_flutter_libs: e7fc8c9ea2200ff3271f08f127842131746b70e2
|
||||
system_info_plus: 555ce7047fbbf29154726db942ae785c29211740
|
||||
thermal: d4c48be750d1ddbab36b0e2dcb2471531bc8df41
|
||||
ua_client_hints: 92fe0d139619b73ec9fcb46cc7e079a26178f586
|
||||
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
|
||||
vibration: 8e2f50fc35bb736f9eecb7dd9f7047fbb6a6e888
|
||||
video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b
|
||||
video_thumbnail: b637e0ad5f588ca9945f6e2c927f73a69a661140
|
||||
volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12
|
||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||
workmanager: b89e4e4445d8b57ee2fdbf1c3925696ebe5b8990
|
||||
sqlite3_flutter_libs: 2c48c4ee7217fd653251975e43412250d5bcbbe2
|
||||
system_info_plus: 5393c8da281d899950d751713575fbf91c7709aa
|
||||
thermal: a9261044101ae8f532fa29cab4e8270b51b3f55c
|
||||
ua_client_hints: aeabd123262c087f0ce151ef96fa3ab77bfc8b38
|
||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||
vibration: 7d883d141656a1c1a6d8d238616b2042a51a1241
|
||||
video_player_avfoundation: 7c6c11d8470e1675df7397027218274b6d2360b3
|
||||
video_thumbnail: c4e2a3c539e247d4de13cd545344fd2d26ffafd1
|
||||
volume_controller: 2e3de73d6e7e81a0067310d17fb70f2f86d71ac7
|
||||
wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e
|
||||
workmanager: 05afacf221f5086e18450250dce57f59bb23e6b0
|
||||
|
||||
PODFILE CHECKSUM: cce2cd3351d3488dca65b151118552b680e23635
|
||||
|
||||
|
||||
@@ -565,6 +565,7 @@
|
||||
"${BUILT_PRODUCTS_DIR}/photo_manager/photo_manager.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/privacy_screen/privacy_screen.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/receive_sharing_intent/receive_sharing_intent.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/rive_common/rive_common.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/rust_lib_photos/rust_lib_photos.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/sentry_flutter/sentry_flutter.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/share_plus/share_plus.framework",
|
||||
@@ -662,6 +663,7 @@
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/photo_manager.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/privacy_screen.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/receive_sharing_intent.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/rive_common.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/rust_lib_photos.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/sentry_flutter.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/share_plus.framework",
|
||||
|
||||
@@ -142,7 +142,7 @@ class _EnteAppState extends State<EnteApp> with WidgetsBindingObserver {
|
||||
debugShowCheckedModeBanner: false,
|
||||
builder: EasyLoading.init(),
|
||||
locale: locale,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
supportedLocales: appSupportedLocales,
|
||||
localeListResolutionCallback: localResolutionCallBack,
|
||||
localizationsDelegates: const [
|
||||
...AppLocalizations.localizationsDelegates,
|
||||
@@ -164,7 +164,7 @@ class _EnteAppState extends State<EnteApp> with WidgetsBindingObserver {
|
||||
debugShowCheckedModeBanner: false,
|
||||
builder: EasyLoading.init(),
|
||||
locale: locale,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
supportedLocales: appSupportedLocales,
|
||||
localeListResolutionCallback: localResolutionCallBack,
|
||||
localizationsDelegates: const [
|
||||
...AppLocalizations.localizationsDelegates,
|
||||
|
||||
@@ -28,6 +28,7 @@ import 'package:photos/services/favorites_service.dart';
|
||||
import "package:photos/services/home_widget_service.dart";
|
||||
import 'package:photos/services/ignored_files_service.dart';
|
||||
import "package:photos/services/machine_learning/face_ml/person/person_service.dart";
|
||||
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';
|
||||
@@ -196,6 +197,7 @@ class Configuration {
|
||||
await CollectionsDB.instance.clearTable();
|
||||
await MemoriesDB.instance.clearTable();
|
||||
await MLDataDB.instance.clearTable();
|
||||
await SimilarImagesService.instance.clearCache();
|
||||
|
||||
await UploadLocksDB.instance.clearTable();
|
||||
await IgnoredFilesService.instance.reset();
|
||||
|
||||
13
mobile/apps/photos/lib/core/exceptions.dart
Normal file
13
mobile/apps/photos/lib/core/exceptions.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
// 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'
|
||||
: 'WidgetUnmountedException';
|
||||
}
|
||||
@@ -39,10 +39,26 @@ class ClipVectorDB {
|
||||
final documentsDirectory = await getApplicationDocumentsDirectory();
|
||||
final String dbPath = join(documentsDirectory.path, _databaseName);
|
||||
_logger.info("Opening vectorDB access: DB path " + dbPath);
|
||||
final vectorDB = VectorDb(
|
||||
filePath: dbPath,
|
||||
dimensions: _embeddingDimension,
|
||||
);
|
||||
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 stats = await getIndexStats(vectorDB);
|
||||
_logger.info("VectorDB connection opened with stats: ${stats.toString()}");
|
||||
|
||||
@@ -279,17 +295,23 @@ class ClipVectorDB {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteIndexFile() async {
|
||||
Future<void> deleteIndexFile({bool undoMigration = false}) async {
|
||||
try {
|
||||
final documentsDirectory = await getApplicationDocumentsDirectory();
|
||||
final String dbPath =
|
||||
join(documentsDirectory.path, _databaseName);
|
||||
final String dbPath = join(documentsDirectory.path, _databaseName);
|
||||
_logger.info("Delete index file: DB path " + dbPath);
|
||||
final file = File(dbPath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
_logger.info("Deleted index file on disk");
|
||||
_vectorDbFuture = null;
|
||||
if (undoMigration) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_kMigrationKey, false);
|
||||
_migrationDone = false;
|
||||
_logger.info("Undid migration flag");
|
||||
}
|
||||
} catch (e, s) {
|
||||
_logger.severe("Error deleting index file on disk", e, s);
|
||||
rethrow;
|
||||
|
||||
@@ -1292,8 +1292,11 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
int processedCount = 0;
|
||||
int weirdCount = 0;
|
||||
int whileCount = 0;
|
||||
const String migrationKey = "clip_vector_db_migration_in_progress";
|
||||
final stopwatch = Stopwatch()..start();
|
||||
try {
|
||||
// Make sure no other heavy compute is running
|
||||
computeController.blockCompute(blocker: migrationKey);
|
||||
while (true) {
|
||||
whileCount++;
|
||||
_logger.info("$whileCount st round of while loop");
|
||||
@@ -1323,6 +1326,9 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
embeddings.add(Float32List.view(result[embeddingColumn].buffer));
|
||||
} else {
|
||||
weirdCount++;
|
||||
_logger.warning(
|
||||
"Weird clip embedding length ${embedding.length} for fileID ${result[fileIDColumn]}, skipping",
|
||||
);
|
||||
}
|
||||
}
|
||||
_logger.info(
|
||||
@@ -1349,7 +1355,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
"migrated all $totalCount embeddings to ClipVectorDB in ${stopwatch.elapsed.inMilliseconds} ms, with $weirdCount weird embeddings not migrated",
|
||||
);
|
||||
await ClipVectorDB.instance.setMigrationDone();
|
||||
_logger.info("ClipVectorDB migration done, flag file created");
|
||||
_logger.info("ClipVectorDB migration done");
|
||||
} catch (e, s) {
|
||||
_logger.severe(
|
||||
"Error migrating ClipVectorDB after ${stopwatch.elapsed.inMilliseconds} ms, clearing out DB again",
|
||||
@@ -1360,6 +1366,8 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
rethrow;
|
||||
} finally {
|
||||
stopwatch.stop();
|
||||
// Make sure compute can run again
|
||||
computeController.unblockCompute(blocker: migrationKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1776,11 +1776,6 @@
|
||||
"same": "نفس",
|
||||
"different": "مختلف",
|
||||
"sameperson": "نفس الشخص؟",
|
||||
"cLTitle1": "محرر الصور المتقدم",
|
||||
"cLDesc1": "نحن بصدد إطلاق محرر صور جديد ومتقدم يضيف المزيد من إطارات الاقتصاص، والإعدادات المسبقة للفلاتر من أجل تعديلات سريعة، وخيارات الضبط الدقيق التي تشمل التشبع، والتباين، والسطوع، ودرجة الحرارة، وغير ذلك الكثير. يتضمن المحرر الجديد أيضا القدرة على الرسم على صورك وإضافة الرموز التعبيرية كملصقات.",
|
||||
"cLTitle2": "ألبومات ذكية",
|
||||
"cLTitle3": "معرض محسن",
|
||||
"cLTitle4": "تمرير أسرع",
|
||||
"thisWeek": "هذا الأسبوع",
|
||||
"lastWeek": "الأسبوع الماضي",
|
||||
"thisMonth": "هذا الشهر",
|
||||
@@ -1821,4 +1816,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +314,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"faq": "Často kladené dotazy",
|
||||
"faq": "Často kladené dotazy (FAQ)",
|
||||
"help": "Nápověda",
|
||||
"oopsSomethingWentWrong": "Jejda, něco se pokazilo",
|
||||
"peopleUsingYourCode": "Lidé, kteří používají váš kód",
|
||||
@@ -498,7 +498,7 @@
|
||||
"authToChangeYourEmail": "Pro změnu e-mailové adresy se prosím ověřte",
|
||||
"changePassword": "Změnit heslo",
|
||||
"authToChangeYourPassword": "Pro změnu hesla se prosím ověřte",
|
||||
"emailVerificationToggle": "Ověření emailem",
|
||||
"emailVerificationToggle": "Ověření pomocí e-mailu",
|
||||
"authToChangeEmailVerificationSetting": "Pro změnu ověření pomocí emailu se musíte ověřit",
|
||||
"exportYourData": "Exportujte svá data",
|
||||
"logout": "Odhlásit se",
|
||||
@@ -594,7 +594,7 @@
|
||||
"theme": "Motiv",
|
||||
"lightTheme": "Světlý",
|
||||
"darkTheme": "Tmavý",
|
||||
"systemTheme": "Podle systému",
|
||||
"systemTheme": "Systém",
|
||||
"freeTrial": "Bezplatná zkušební verze",
|
||||
"selectYourPlan": "Vyberte svůj tarif",
|
||||
"enteSubscriptionPitch": "Ente uchovává vaše vzpomínky, takže jsou vám vždy k dispozici, i když ztratíte své zařízení.",
|
||||
@@ -1776,14 +1776,6 @@
|
||||
"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",
|
||||
@@ -1827,5 +1819,98 @@
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"videosProcessed": "Zpracovaná videa",
|
||||
"totalVideos": "Celkový počet videí",
|
||||
"skippedVideos": "Přeskočená videa",
|
||||
"videoStreamingNote": "Na tomto zařízení se zpracovávají pouze videa z posledních 60 dnů, která jsou kratší než 1 minuta. U starších/delších videí povolte streamování v desktopové aplikaci.",
|
||||
"createStream": "Vytvořit stream",
|
||||
"recreateStream": "Obnovit stream",
|
||||
"addedToStreamCreationQueue": "Přidáno do fronty pro vytvoření streamu",
|
||||
"addedToStreamRecreationQueue": "Přidáno do fronty pro obnovení streamu",
|
||||
"videoPreviewAlreadyExists": "Náhled videa již existuje",
|
||||
"videoAlreadyInQueue": "Video soubor již je ve frontě",
|
||||
"addedToQueue": "Přidáno do fronty",
|
||||
"creatingStream": "Vytváření streamu",
|
||||
"similarImages": "Podobné obrázky",
|
||||
"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": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reviewAndRemoveSimilarImages": "Zkontrolujte a odstraňte podobné obrázky",
|
||||
"deletePhotosWithSize": "Smazat {count} fotek ({size})",
|
||||
"@deletePhotosWithSize": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "String"
|
||||
},
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionOptions": "Možnosti výběru",
|
||||
"selectExactWithCount": "Úplně stejné ({count})",
|
||||
"@selectExactWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectExact": "Vybrat shodné",
|
||||
"selectSimilarWithCount": "Hodně podobné ({count})",
|
||||
"@selectSimilarWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectSimilar": "Vyberte podobné",
|
||||
"selectAllWithCount": "Všechny podobnosti ({count})",
|
||||
"@selectAllWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectSimilarImagesTitle": "Vyberte podobné obrázky",
|
||||
"chooseSimilarImagesToSelect": "Vyberte obrázky na základě jejich vizuální podobnosti",
|
||||
"clearSelection": "Vymazat výběr",
|
||||
"similarImagesCount": "{count} podobných obrázků",
|
||||
"@similarImagesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteWithCount": "Smazat ({count})",
|
||||
"@deleteWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteFiles": "Smazat soubory",
|
||||
"areYouSureDeleteFiles": "Opravdu chcete tyto soubory smazat?",
|
||||
"greatJob": "Dobrá práce!",
|
||||
"size": "Velikost",
|
||||
"similarity": "Podobnost",
|
||||
"processingLocally": "Místní zpracování",
|
||||
"useMLToFindSimilarImages": "Zkontrolujte a odstraňte obrázky, které se navzájem podobají.",
|
||||
"all": "Vše",
|
||||
"similar": "Podobné",
|
||||
"identical": "Identické",
|
||||
"nothingHereTryAnotherFilter": "Tady nic není, zkuste jiný filtr! 👀"
|
||||
}
|
||||
|
||||
@@ -1776,14 +1776,6 @@
|
||||
"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",
|
||||
@@ -1827,5 +1819,117 @@
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"videosProcessed": "Videos verarbeitet",
|
||||
"totalVideos": "Videos insgesamt",
|
||||
"skippedVideos": "Übersprungene Videos",
|
||||
"videoStreamingDescriptionLine1": "Videos sofort auf jedem Gerät abspielen.",
|
||||
"videoStreamingDescriptionLine2": "Aktivieren, um Video-Streams auf diesem Gerät zu verarbeiten.",
|
||||
"videoStreamingNote": "Nur Videos der letzten 60 Tage und unter einer Minute werden auf diesem Gerät verarbeitet. Für ältere/längere Videos aktiviere das Streaming in der Desktop-App.",
|
||||
"createStream": "Stream erzeugen",
|
||||
"recreateStream": "Stream neu erzeugen",
|
||||
"addedToStreamCreationQueue": "Zur Warteschlange für Streamerstellung hinzugefügt",
|
||||
"addedToStreamRecreationQueue": "Zur Warteschlange für Neuerstellung der Streams hinzugefügt",
|
||||
"videoPreviewAlreadyExists": "Videovorschau existiert bereits",
|
||||
"videoAlreadyInQueue": "Videodatei existiert bereits in der Warteschlange",
|
||||
"addedToQueue": "Zur Warteschlange hinzugefügt",
|
||||
"creatingStream": "Stream wird erzeugt",
|
||||
"similarImages": "Ähnliche Bilder",
|
||||
"findSimilarImages": "Ähnliche Bilder finden",
|
||||
"noSimilarImagesFound": "Keine ähnlichen Bilder gefunden",
|
||||
"yourPhotosLookUnique": "Deine Fotos sehen einzigartig aus",
|
||||
"similarGroupsFound": "{count, plural, =1{Eine Gruppe gefunden} other{{count} Gruppen gefunden}}",
|
||||
"@similarGroupsFound": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reviewAndRemoveSimilarImages": "Überprüfe und lösche ähnliche Bilder",
|
||||
"deletePhotosWithSize": "Lösche {count} Fotos ({size})",
|
||||
"@deletePhotosWithSize": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "String"
|
||||
},
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionOptions": "Auswahloptionen",
|
||||
"selectExactWithCount": "Exakt gleich ({count})",
|
||||
"@selectExactWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectExact": "Exakte auswählen",
|
||||
"selectSimilarWithCount": "Nahezu gleich ({count})",
|
||||
"@selectSimilarWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectSimilar": "Ähnliche auswählen",
|
||||
"selectAllWithCount": "Alle Ähnlichkeiten ({count})",
|
||||
"@selectAllWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectSimilarImagesTitle": "Ähnliche Bilder auswählen",
|
||||
"chooseSimilarImagesToSelect": "Wähle Bilder anhand ihrer visuellen Ähnlichkeit",
|
||||
"clearSelection": "Auswahl aufheben",
|
||||
"similarImagesCount": "{count} ähnliche Bilder",
|
||||
"@similarImagesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteWithCount": "Löschen ({count})",
|
||||
"@deleteWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteFiles": "Dateien löschen",
|
||||
"areYouSureDeleteFiles": "Bist du sicher, dass du diese Dateien löschen willst?",
|
||||
"greatJob": "Gut gemacht!",
|
||||
"cleanedUpSimilarImages": "Du hast {size} an Speicherplatz freigegeben",
|
||||
"@cleanedUpSimilarImages": {
|
||||
"placeholders": {
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"size": "Größe",
|
||||
"similarity": "Ähnlichkeit",
|
||||
"processingLocally": "Lokale Verarbeitung",
|
||||
"useMLToFindSimilarImages": "Überprüfe und entferne Bilder, die sich ähnlich sehen.",
|
||||
"all": "Alle",
|
||||
"similar": "Ähnlich",
|
||||
"identical": "Identisch",
|
||||
"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"
|
||||
}
|
||||
@@ -1776,14 +1776,6 @@
|
||||
"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",
|
||||
@@ -1843,14 +1835,6 @@
|
||||
"addedToQueue": "Added to queue",
|
||||
"creatingStream": "Creating stream",
|
||||
"similarImages": "Similar images",
|
||||
"deletingProgress": "Deleting... {progress}",
|
||||
"@deletingProgress": {
|
||||
"placeholders": {
|
||||
"progress": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"findSimilarImages": "Find similar images",
|
||||
"noSimilarImagesFound": "No similar images found",
|
||||
"yourPhotosLookUnique": "Your photos look unique",
|
||||
@@ -1933,11 +1917,11 @@
|
||||
},
|
||||
"size": "Size",
|
||||
"similarity": "Similarity",
|
||||
"analyzingPhotosLocally": "Analyzing your photos locally",
|
||||
"lookingForVisualSimilarities": "Looking for visual similarities",
|
||||
"comparingImageDetails": "Comparing image details",
|
||||
"findingSimilarImages": "Finding similar images",
|
||||
"almostDone": "Almost done",
|
||||
"analyzingPhotosLocally": "Analyzing your photos locally...",
|
||||
"lookingForVisualSimilarities": "Looking for visual similarities...",
|
||||
"comparingImageDetails": "Comparing image details...",
|
||||
"findingSimilarImages": "Finding similar images...",
|
||||
"almostDone": "Almost done...",
|
||||
"processingLocally": "Processing locally",
|
||||
"useMLToFindSimilarImages": "Review and remove images that look similar to each other.",
|
||||
"all": "All",
|
||||
@@ -1946,5 +1930,12 @@
|
||||
"nothingHereTryAnotherFilter": "Nothing here, try another filter! 👀",
|
||||
"related": "Related",
|
||||
"hoorayyyy": "Hoorayyyy!",
|
||||
"nothingToTidyUpHere": "Nothing to tidy up here"
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
"addCollaborator": "Agregar colaborador",
|
||||
"addANewEmail": "Agregar nuevo correo electrónico",
|
||||
"orPickAnExistingOne": "O elige uno existente",
|
||||
"collaboratorsCanAddPhotosAndVideosToTheSharedAlbum": "Colaboradores pueden añadir fotos y videos al álbum compartido.",
|
||||
"collaboratorsCanAddPhotosAndVideosToTheSharedAlbum": "Colaboradores pueden añadir fotos y vídeos al álbum compartido.",
|
||||
"enterEmail": "Ingresar correo electrónico ",
|
||||
"albumOwner": "Propietario",
|
||||
"@albumOwner": {
|
||||
@@ -270,7 +270,7 @@
|
||||
"shareTextConfirmOthersVerificationID": "Hola, ¿puedes confirmar que esta es tu ID de verificación ente.io: {verificationID}?",
|
||||
"somethingWentWrong": "Algo salió mal",
|
||||
"sendInvite": "Enviar invitación",
|
||||
"shareTextRecommendUsingEnte": "Descarga Ente para que podamos compartir fácilmente fotos y videos en calidad original.\n\nhttps://ente.io",
|
||||
"shareTextRecommendUsingEnte": "Descarga Ente para que podamos compartir fácilmente fotos y vídeos en calidad original.\n\nhttps://ente.io",
|
||||
"done": "Hecho",
|
||||
"applyCodeTitle": "Usar código",
|
||||
"enterCodeDescription": "Introduce el código proporcionado por tu amigo para reclamar almacenamiento gratuito para ambos",
|
||||
@@ -857,7 +857,7 @@
|
||||
"deviceFilesAutoUploading": "Los archivos añadidos a este álbum de dispositivo se subirán automáticamente a Ente.",
|
||||
"turnOnBackupForAutoUpload": "Activar la copia de seguridad para subir automáticamente archivos añadidos a la carpeta de este dispositivo a Ente.",
|
||||
"noHiddenPhotosOrVideos": "No hay fotos ni vídeos ocultos",
|
||||
"toHideAPhotoOrVideo": "Para ocultar una foto o video",
|
||||
"toHideAPhotoOrVideo": "Para ocultar una foto o vídeo",
|
||||
"openTheItem": "• Abrir el elemento",
|
||||
"clickOnTheOverflowMenu": "• Haga clic en el menú desbordante",
|
||||
"click": "• Clic",
|
||||
@@ -866,7 +866,7 @@
|
||||
"archiveAlbum": "Archivar álbum",
|
||||
"calculating": "Calculando...",
|
||||
"pleaseWaitDeletingAlbum": "Por favor espera. Borrando el álbum",
|
||||
"searchByExamples": "• Nombres de álbumes (por ejemplo, \"Cámara\")\n• Tipos de archivos (por ejemplo, \"Videos\", \".gif\")\n• Años y meses (por ejemplo, \"2022\", \"Enero\")\n• Vacaciones (por ejemplo, \"Navidad\")\n• Descripciones fotográficas (por ejemplo, \"#diversión\")",
|
||||
"searchByExamples": "• Nombres de álbumes (por ejemplo, \"Cámara\")\n• Tipos de archivos (por ejemplo, \"Vídeos\", \".gif\")\n• Años y meses (por ejemplo, \"2022\", \"Enero\")\n• Vacaciones (por ejemplo, \"Navidad\")\n• Descripciones fotográficas (por ejemplo, \"#diversión\")",
|
||||
"youCanTrySearchingForADifferentQuery": "Puedes intentar buscar una consulta diferente.",
|
||||
"noResultsFound": "No se han encontrado resultados",
|
||||
"addedBy": "Añadido por {emailOrName}",
|
||||
@@ -884,8 +884,8 @@
|
||||
"filesSavedToGallery": "Archivo guardado en la galería",
|
||||
"fileFailedToSaveToGallery": "No se pudo guardar el archivo en la galería",
|
||||
"download": "Descargar",
|
||||
"pressAndHoldToPlayVideo": "Presiona y mantén presionado para reproducir el video",
|
||||
"pressAndHoldToPlayVideoDetailed": "Mantén pulsada la imagen para reproducir el video",
|
||||
"pressAndHoldToPlayVideo": "Presiona y mantén presionado para reproducir el vídeo",
|
||||
"pressAndHoldToPlayVideoDetailed": "Mantén pulsada la imagen para reproducir el vídeo",
|
||||
"downloadFailed": "Descarga fallida",
|
||||
"deduplicateFiles": "Deduplicar archivos",
|
||||
"deselectAll": "Deseleccionar todo",
|
||||
@@ -895,7 +895,7 @@
|
||||
"count": "Cuenta",
|
||||
"totalSize": "Tamaño total",
|
||||
"longpressOnAnItemToViewInFullscreen": "Manten presionado un elemento para ver en pantalla completa",
|
||||
"decryptingVideo": "Descifrando video...",
|
||||
"decryptingVideo": "Descifrando vídeo...",
|
||||
"authToViewYourMemories": "Por favor, autentícate para ver tus recuerdos",
|
||||
"unlock": "Desbloquear",
|
||||
"freeUpSpace": "Liberar espacio",
|
||||
@@ -1014,7 +1014,7 @@
|
||||
"cachedData": "Datos almacenados en caché",
|
||||
"clearCaches": "Limpiar cachés",
|
||||
"remoteImages": "Imágenes remotas",
|
||||
"remoteVideos": "Videos remotos",
|
||||
"remoteVideos": "Vídeos remotos",
|
||||
"remoteThumbnails": "Miniaturas remotas",
|
||||
"pendingSync": "Sincronización pendiente",
|
||||
"localGallery": "Galería local",
|
||||
@@ -1169,7 +1169,7 @@
|
||||
"description": "Label for the map view"
|
||||
},
|
||||
"maps": "Mapas",
|
||||
"enableMaps": "Activar Mapas",
|
||||
"enableMaps": "Habilitar mapas",
|
||||
"enableMapsDesc": "Esto mostrará tus fotos en el mapa mundial.\n\nEste mapa está gestionado por Open Street Map, y la ubicación exacta de tus fotos nunca se comparte.\n\nPuedes deshabilitar esta función en cualquier momento en Ajustes.",
|
||||
"quickLinks": "Acceso rápido",
|
||||
"selectItemsToAdd": "Selecciona elementos para agregar",
|
||||
@@ -1346,7 +1346,7 @@
|
||||
"noSystemLockFound": "Bloqueo de sistema no encontrado",
|
||||
"tapToUnlock": "Toca para desbloquear",
|
||||
"tooManyIncorrectAttempts": "Demasiados intentos incorrectos",
|
||||
"videoInfo": "Información de video",
|
||||
"videoInfo": "Información de vídeo",
|
||||
"autoLock": "Bloqueo automático",
|
||||
"immediately": "Inmediatamente",
|
||||
"autoLockFeatureDescription": "Tiempo después de que la aplicación esté en segundo plano",
|
||||
@@ -1433,7 +1433,7 @@
|
||||
"description": "In session page, warn user (in toast) that active sessions could not be fetched."
|
||||
},
|
||||
"failedToRefreshStripeSubscription": "Error al actualizar la suscripción",
|
||||
"failedToPlayVideo": "Error al reproducir el video",
|
||||
"failedToPlayVideo": "Error al reproducir el vídeo",
|
||||
"uploadIsIgnoredDueToIgnorereason": "La subida se ignoró debido a {ignoreReason}",
|
||||
"@uploadIsIgnoredDueToIgnorereason": {
|
||||
"placeholders": {
|
||||
@@ -1588,7 +1588,7 @@
|
||||
},
|
||||
"legacyInvite": "{email} te ha invitado a ser un contacto de confianza",
|
||||
"authToManageLegacy": "Por favor, autentícate para administrar tus contactos de confianza",
|
||||
"useDifferentPlayerInfo": "¿Tienes problemas para reproducir este video? Mantén pulsado aquí para probar un reproductor diferente.",
|
||||
"useDifferentPlayerInfo": "¿Tienes problemas para reproducir este vídeo? Mantén pulsado aquí para probar un reproductor diferente.",
|
||||
"hideSharedItemsFromHomeGallery": "Ocultar elementos compartidos de la galería de inicio",
|
||||
"gallery": "Galería",
|
||||
"joinAlbum": "Unir álbum",
|
||||
@@ -1662,7 +1662,7 @@
|
||||
"@linkPersonCaption": {
|
||||
"description": "Caption for the 'Link person' title. It should be a continuation of the 'Link person' title. Just like how 'Link person' + 'for better sharing experience' forms a proper sentence in English, the combination of these two strings should also be a proper sentence in other languages."
|
||||
},
|
||||
"videoStreaming": "Vídeos en streaming",
|
||||
"videoStreaming": "Vídeos en transmisión",
|
||||
"processingVideos": "Procesando vídeos",
|
||||
"streamDetails": "Detalles de la transmisión",
|
||||
"processing": "Procesando",
|
||||
@@ -1776,14 +1776,6 @@
|
||||
"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",
|
||||
@@ -1827,5 +1819,117 @@
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"videosProcessed": "Vídeos procesados",
|
||||
"totalVideos": "Vídeos totales",
|
||||
"skippedVideos": "Vídeos omitidos",
|
||||
"videoStreamingDescriptionLine1": "Reproduce vídeos al instante en cualquier dispositivo.",
|
||||
"videoStreamingDescriptionLine2": "Habilitar para procesar transmisiones de vídeo en este dispositivo.",
|
||||
"videoStreamingNote": "Solo los vídeos de los últimos 60 días y menos de 1 minuto se procesan en este dispositivo. Para vídeos más viejos/más largos, habilita la transmisión en la aplicación de escritorio.",
|
||||
"createStream": "Crear transmisión",
|
||||
"recreateStream": "Recrear transmisión",
|
||||
"addedToStreamCreationQueue": "Añadido a la cola de creación de transmisiones",
|
||||
"addedToStreamRecreationQueue": "Añadido a la cola de grabación de transmisiones",
|
||||
"videoPreviewAlreadyExists": "La vista previa de vídeo ya existe",
|
||||
"videoAlreadyInQueue": "El archivo de vídeo ya está en la cola",
|
||||
"addedToQueue": "Añadido a la cola",
|
||||
"creatingStream": "Creando transmisión",
|
||||
"similarImages": "Imágenes similares",
|
||||
"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": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reviewAndRemoveSimilarImages": "Revisar y eliminar imágenes similares",
|
||||
"deletePhotosWithSize": "Eliminar {count} fotos ({size})",
|
||||
"@deletePhotosWithSize": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "String"
|
||||
},
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionOptions": "Opciones de selección",
|
||||
"selectExactWithCount": "Exactamente similar ({count})",
|
||||
"@selectExactWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectExact": "Seleccionar exactos",
|
||||
"selectSimilarWithCount": "Mayormente, similar ({count})",
|
||||
"@selectSimilarWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectSimilar": "Seleccionar similares",
|
||||
"selectAllWithCount": "Todas las similitudes ({count})",
|
||||
"@selectAllWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectSimilarImagesTitle": "Seleccionar imágenes similares",
|
||||
"chooseSimilarImagesToSelect": "Seleccionar imágenes basándose en su similitud visual",
|
||||
"clearSelection": "Borrar selección",
|
||||
"similarImagesCount": "{count} imágenes similares",
|
||||
"@similarImagesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteWithCount": "Eliminar ({count})",
|
||||
"@deleteWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteFiles": "Eliminar archivos",
|
||||
"areYouSureDeleteFiles": "¿Estás seguro que quieres eliminar estos archivos?",
|
||||
"greatJob": "¡Bien hecho!",
|
||||
"cleanedUpSimilarImages": "Has liberado {size} de espacio",
|
||||
"@cleanedUpSimilarImages": {
|
||||
"placeholders": {
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"size": "Tamaño",
|
||||
"similarity": "Similitud",
|
||||
"processingLocally": "Procesando localmente",
|
||||
"useMLToFindSimilarImages": "Revisar y eliminar imágenes que se parecen entre sí.",
|
||||
"all": "Todas",
|
||||
"similar": "Similares",
|
||||
"identical": "Idénticas",
|
||||
"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"
|
||||
}
|
||||
@@ -1776,14 +1776,6 @@
|
||||
"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",
|
||||
@@ -1827,5 +1819,109 @@
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"videosProcessed": "Vidéos traitées",
|
||||
"totalVideos": "Total de vidéos",
|
||||
"skippedVideos": "Vidéos ignorées",
|
||||
"videoStreamingDescriptionLine1": "Lire instantanément des vidéos sur n'importe quel appareil.",
|
||||
"videoStreamingDescriptionLine2": "Activer pour traiter les flux vidéo sur cet appareil.",
|
||||
"videoStreamingNote": "Seules les vidéos des 60 derniers jours et de moins d'une minute sont traitées sur cet appareil. Pour les vidéos plus anciennes/plus longues, activez le streaming dans l'application pour ordinateur.",
|
||||
"createStream": "Créer le flux",
|
||||
"recreateStream": "Recréer le flux",
|
||||
"addedToStreamCreationQueue": "Ajouté à la file d'attente de création de flux",
|
||||
"addedToStreamRecreationQueue": "Ajouté à la file d'attente de re-création de flux",
|
||||
"videoPreviewAlreadyExists": "L'aperçu vidéo existe déjà",
|
||||
"videoAlreadyInQueue": "Fichier vidéo déjà présent dans la file d'attente",
|
||||
"addedToQueue": "Ajouté à la file d'attente",
|
||||
"creatingStream": "Création du flux",
|
||||
"similarImages": "Images similaires",
|
||||
"findSimilarImages": "Rechercher des images similaires",
|
||||
"noSimilarImagesFound": "Aucune image similaire trouvée",
|
||||
"yourPhotosLookUnique": "Vos photos semblent uniques",
|
||||
"reviewAndRemoveSimilarImages": "Examiner et supprimer les images similaires",
|
||||
"deletePhotosWithSize": "Supprimer {count} photos ({size})",
|
||||
"@deletePhotosWithSize": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "String"
|
||||
},
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionOptions": "Options de sélection",
|
||||
"selectExactWithCount": "Exactement similaire ({count})",
|
||||
"@selectExactWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectExact": "Sélectionner exactement",
|
||||
"selectSimilarWithCount": "Plutôt similaire ({count})",
|
||||
"@selectSimilarWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectSimilar": "Sélectionner à l'identique",
|
||||
"selectAllWithCount": "Toutes les similarités ({count})",
|
||||
"@selectAllWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectSimilarImagesTitle": "Sélectionner les images similaires",
|
||||
"chooseSimilarImagesToSelect": "Sélectionnez des images en fonction de leur similitude visuelle",
|
||||
"clearSelection": "Effacer la sélection",
|
||||
"similarImagesCount": "{count} images similaires",
|
||||
"@similarImagesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteWithCount": "Supprimer ({count})",
|
||||
"@deleteWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteFiles": "Supprimer les fichiers",
|
||||
"areYouSureDeleteFiles": "Êtes-vous sûr de vouloir supprimer ces fichiers ?",
|
||||
"greatJob": "Excellent !",
|
||||
"cleanedUpSimilarImages": "Vous avez libéré {size} d'espace",
|
||||
"@cleanedUpSimilarImages": {
|
||||
"placeholders": {
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"size": "Taille",
|
||||
"similarity": "Similitude",
|
||||
"processingLocally": "Traitement local",
|
||||
"useMLToFindSimilarImages": "Examinez et supprimez les images qui se ressemblent entre elles.",
|
||||
"all": "Toutes",
|
||||
"similar": "Similaires",
|
||||
"identical": "Identiques",
|
||||
"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"
|
||||
}
|
||||
@@ -189,6 +189,7 @@
|
||||
"allowAddPhotosDescription": "Izinkan orang yang memiliki link untuk menambahkan foto ke album berbagi ini.",
|
||||
"passwordLock": "Kunci dengan sandi",
|
||||
"canNotOpenTitle": "Tidak dapat membuka album ini",
|
||||
"canNotOpenBody": "Maaf, album ini tidak dapat dibuka di aplikasi.",
|
||||
"disableDownloadWarningTitle": "Perlu diketahui",
|
||||
"disableDownloadWarningBody": "Orang yang melihat masih bisa mengambil tangkapan layar atau menyalin foto kamu menggunakan alat eksternal",
|
||||
"allowDownloads": "Izinkan pengunduhan",
|
||||
@@ -355,6 +356,7 @@
|
||||
"failedToLoadAlbums": "Gagal memuat album",
|
||||
"hidden": "Tersembunyi",
|
||||
"authToViewYourHiddenFiles": "Harap autentikasi untuk melihat file tersembunyi kamu",
|
||||
"authToViewTrashedFiles": "Silakan autentikasi untuk melihat file anda yang ada di tong sampah",
|
||||
"trash": "Sampah",
|
||||
"uncategorized": "Tak Berkategori",
|
||||
"videoSmallCase": "video",
|
||||
@@ -370,6 +372,21 @@
|
||||
"deleteFromBoth": "Hapus dari keduanya",
|
||||
"newAlbum": "Album baru",
|
||||
"albums": "Album",
|
||||
"memoryCount": "{count, plural, =0{tidak ada memori} one{{formattedCount} memori} other{{formattedCount} memori}}",
|
||||
"@memoryCount": {
|
||||
"description": "The text to display the number of memories",
|
||||
"type": "text",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"example": "1",
|
||||
"type": "int"
|
||||
},
|
||||
"formattedCount": {
|
||||
"type": "String",
|
||||
"example": "11.513, 11,511"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectedPhotos": "{count} terpilih",
|
||||
"@selectedPhotos": {
|
||||
"description": "Display the number of selected photos",
|
||||
@@ -419,6 +436,7 @@
|
||||
"discover_receipts": "Tanda Terima",
|
||||
"discover_notes": "Catatan",
|
||||
"discover_memes": "Meme",
|
||||
"discover_visiting_cards": "Kartu Nama",
|
||||
"discover_babies": "Bayi",
|
||||
"discover_pets": "Hewan",
|
||||
"discover_selfies": "Swafoto",
|
||||
@@ -427,6 +445,7 @@
|
||||
"discover_celebrations": "Perayaan",
|
||||
"discover_sunset": "Senja",
|
||||
"discover_hills": "Bukit",
|
||||
"discover_greenery": "Hijau-hijauan",
|
||||
"mlIndexingDescription": "Perlu diperhatikan bahwa pemelajaran mesin dapat meningkatkan penggunaan data dan baterai perangkat hingga seluruh item selesai terindeks. Gunakan aplikasi desktop untuk pengindeksan lebih cepat, seluruh hasil akan tersinkronkan secara otomatis.",
|
||||
"loadingModel": "Mengunduh model...",
|
||||
"waitingForWifi": "Menunggu WiFi...",
|
||||
@@ -442,6 +461,21 @@
|
||||
"updatingFolderSelection": "Memperbaharui pilihan folder...",
|
||||
"itemCount": "{count, plural, other{{count} item}}",
|
||||
"deleteItemCount": "{count, plural, =1 {Hapus {count} item} other {Hapus {count} item}}",
|
||||
"duplicateItemsGroup": "{count} berkas, masing-masing {formattedSize}",
|
||||
"@duplicateItemsGroup": {
|
||||
"description": "Display the number of duplicate files and their size",
|
||||
"type": "text",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"example": "12",
|
||||
"type": "int"
|
||||
},
|
||||
"formattedSize": {
|
||||
"example": "2.3 MB",
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showMemories": "Lihat kenangan",
|
||||
"yearsAgo": "{count, plural, other{{count} tahun lalu}}",
|
||||
"backupSettings": "Pengaturan pencadangan",
|
||||
@@ -492,6 +526,7 @@
|
||||
"viewLargeFiles": "File berukuran besar",
|
||||
"viewLargeFilesDesc": "Tampilkan file yang paling besar mengonsumsi ruang penyimpanan.",
|
||||
"noDuplicates": "✨ Tak ada file duplikat",
|
||||
"youveNoDuplicateFilesThatCanBeCleared": "Anda tidak memiliki file duplikat yang dapat dihapus",
|
||||
"success": "Berhasil",
|
||||
"rateUs": "Beri kami nilai",
|
||||
"remindToEmptyDeviceTrash": "Kosongkan juga “Baru Dihapus” dari “Pengaturan” -> “Penyimpanan” untuk memperoleh ruang yang baru saja dibersihkan",
|
||||
@@ -658,6 +693,7 @@
|
||||
"rateTheApp": "Nilai app ini",
|
||||
"startBackup": "Mulai pencadangan",
|
||||
"noPhotosAreBeingBackedUpRightNow": "Tidak ada foto yang sedang dicadangkan sekarang",
|
||||
"preserveMore": "Amankan lebih banyak",
|
||||
"grantFullAccessPrompt": "Harap berikan akses ke semua foto di app Pengaturan",
|
||||
"allowPermTitle": "Izinkan akses ke foto",
|
||||
"allowPermBody": "Ijinkan akses ke foto Anda dari Pengaturan agar Ente dapat menampilkan dan mencadangkan pustaka Anda.",
|
||||
@@ -714,6 +750,7 @@
|
||||
"lastUpdated": "Terakhir diperbarui",
|
||||
"deleteEmptyAlbums": "Hapus album kosong",
|
||||
"deleteEmptyAlbumsWithQuestionMark": "Hapus album yang kosong?",
|
||||
"deleteAlbumsDialogBody": "Ini akan menghapus semua album kosong. Ini berguna ketika anda ingin mengurangi kekacauan di daftar album anda.",
|
||||
"deleteProgress": "Menghapus {currentlyDeleting} / {totalCount}",
|
||||
"genericProgress": "Memproses {currentlyProcessing} / {totalCount}",
|
||||
"@genericProgress": {
|
||||
@@ -731,6 +768,7 @@
|
||||
}
|
||||
},
|
||||
"permanentlyDelete": "Hapus secara permanen",
|
||||
"canOnlyCreateLinkForFilesOwnedByYou": "Hanya dapat membuat tautan untuk file yang dimiliki oleh anda",
|
||||
"publicLinkCreated": "Link publik dibuat",
|
||||
"youCanManageYourLinksInTheShareTab": "Kamu bisa atur link yang telah kamu buat di tab berbagi.",
|
||||
"linkCopiedToClipboard": "Link tersalin ke papan klip",
|
||||
@@ -740,15 +778,30 @@
|
||||
"type": "text"
|
||||
},
|
||||
"moveToAlbum": "Pindahkan ke album",
|
||||
"unhide": "Tampilkan",
|
||||
"unarchive": "Keluarkan dari arsip",
|
||||
"favorite": "Favorit",
|
||||
"removeFromFavorite": "Hapus dari favorit",
|
||||
"shareLink": "Bagikan link",
|
||||
"createCollage": "Buat kolase",
|
||||
"saveCollage": "Simpan kolase",
|
||||
"collageSaved": "Kolase tersimpan ke galeri",
|
||||
"collageLayout": "Tata letak",
|
||||
"addToEnte": "Tambah ke Ente",
|
||||
"addToAlbum": "Tambah ke album",
|
||||
"delete": "Hapus",
|
||||
"hide": "Sembunyikan",
|
||||
"share": "Bagikan",
|
||||
"unhideToAlbum": "Tampikan ke album",
|
||||
"restoreToAlbum": "Pulihkan ke album",
|
||||
"moveItem": "{count, plural, =1 {Pindahkan item} other {Pindahkan item}}",
|
||||
"@moveItem": {
|
||||
"description": "Page title while moving one or more items to an album"
|
||||
},
|
||||
"addItem": "{count, plural, =1 {Tambahkan item} other {Tambahkan item}}",
|
||||
"@addItem": {
|
||||
"description": "Page title while adding one or more items to album"
|
||||
},
|
||||
"createOrSelectAlbum": "Buat atau pilih album",
|
||||
"selectAlbum": "Pilih album",
|
||||
"searchByAlbumNameHint": "Nama album",
|
||||
@@ -756,18 +809,33 @@
|
||||
"enterAlbumName": "Masukkan nama album",
|
||||
"restoringFiles": "Memulihkan file...",
|
||||
"movingFilesToAlbum": "Memindahkan file ke album...",
|
||||
"unhidingFilesToAlbum": "Tampilkan berkas ke album",
|
||||
"canNotUploadToAlbumsOwnedByOthers": "Tidak dapat mengunggah album yang dimiliki oleh orang lain",
|
||||
"uploadingFilesToAlbum": "Mengunggah file ke album...",
|
||||
"addedSuccessfullyTo": "Berhasil ditambahkan ke {albumName}",
|
||||
"movedSuccessfullyTo": "Berhasil dipindahkan ke {albumName}",
|
||||
"thisAlbumAlreadyHDACollaborativeLink": "Link kolaborasi untuk album ini sudah terbuat",
|
||||
"collaborativeLinkCreatedFor": "Link kolaborasi terbuat untuk {albumName}",
|
||||
"askYourLovedOnesToShare": "Minta orang terkasih anda untuk berbagi",
|
||||
"invite": "Undang",
|
||||
"shareYourFirstAlbum": "Bagikan album pertamamu",
|
||||
"sharedWith": "Dibagikan dengan {emailIDs}",
|
||||
"sharedWithMe": "Dibagikan dengan saya",
|
||||
"sharedByMe": "Dibagikan oleh saya",
|
||||
"doubleYourStorage": "Gandakan kuota kamu",
|
||||
"referFriendsAnd2xYourPlan": "Ajak teman dan gandakan paket anda",
|
||||
"shareAlbumHint": "Buka album lalu ketuk tombol bagikan di sudut kanan atas untuk berbagi.",
|
||||
"itemsShowTheNumberOfDaysRemainingBeforePermanentDeletion": "Item menampilkan jumlah hari yang tersisa sebelum dihapus permanen",
|
||||
"trashDaysLeft": "{count, plural, =0 {Segera} =1 {1 hari} other {{count} hari}}",
|
||||
"@trashDaysLeft": {
|
||||
"description": "Text to indicate number of days remaining before permanent deletion",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"example": "1|2|3",
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteAll": "Hapus Semua",
|
||||
"renameAlbum": "Ubah nama album",
|
||||
"convertToAlbum": "Ubah menjadi album",
|
||||
@@ -782,13 +850,16 @@
|
||||
"leaveSharedAlbum": "Tinggalkan album bersama?",
|
||||
"leaveAlbum": "Tinggalkan album",
|
||||
"photosAddedByYouWillBeRemovedFromTheAlbum": "Foto yang telah kamu tambahkan akan dihapus dari album ini",
|
||||
"youveNoFilesInThisAlbumThatCanBeDeleted": "Anda tidak memiliki file di album ini yang dapat dihapus",
|
||||
"youDontHaveAnyArchivedItems": "Kamu tidak memiliki item di arsip.",
|
||||
"ignoredFolderUploadReason": "Sejumlah file di album ini tidak terunggah karena telah dihapus sebelumnya dari Ente.",
|
||||
"resetIgnoredFiles": "Atur ulang file yang diabaikan",
|
||||
"deviceFilesAutoUploading": "File yang ditambahkan ke album perangkat ini akan diunggah ke Ente secara otomatis.",
|
||||
"turnOnBackupForAutoUpload": "Aktifkan pencadangan untuk mengunggah file yang ditambahkan ke folder ini ke Ente secara otomatis.",
|
||||
"noHiddenPhotosOrVideos": "Tidak ada foto atau video tersembunyi",
|
||||
"toHideAPhotoOrVideo": "Untuk menyembunyikan foto atau video",
|
||||
"openTheItem": "• Buka item-nya",
|
||||
"clickOnTheOverflowMenu": "• Klik pada menu overflow",
|
||||
"click": "• Click",
|
||||
"nothingToSeeHere": "Tidak ada apa-apa di sini! 👀",
|
||||
"unarchiveAlbum": "Keluarkan album dari arsip",
|
||||
@@ -796,6 +867,7 @@
|
||||
"calculating": "Menghitung...",
|
||||
"pleaseWaitDeletingAlbum": "Harap tunggu, sedang menghapus album",
|
||||
"searchByExamples": "• Nama album (cth. \"Kamera\")\n• Jenis file (cth. \"Video\", \".gif\")\n• Tahun atau bulan (cth. \"2022\", \"Januari\")\n• Musim liburan (cth. \"Natal\")\n• Keterangan foto (cth. “#seru”)",
|
||||
"youCanTrySearchingForADifferentQuery": "Anda dapat mencoba mencari dengan kata-kata yang berbeda",
|
||||
"noResultsFound": "Tidak ditemukan hasil",
|
||||
"addedBy": "Ditambahkan oleh {emailOrName}",
|
||||
"loadingExifData": "Memuat data EXIF...",
|
||||
@@ -804,6 +876,7 @@
|
||||
"thisImageHasNoExifData": "Gambar ini tidak memiliki data exif",
|
||||
"exif": "EXIF",
|
||||
"noResults": "Tidak ada hasil",
|
||||
"weDontSupportEditingPhotosAndAlbumsThatYouDont": "Kami belum mendukung pengeditan foto dan album yang bukan milik anda",
|
||||
"failedToFetchOriginalForEdit": "Gagal memuat file asli untuk mengedit",
|
||||
"close": "Tutup",
|
||||
"setAs": "Pasang sebagai",
|
||||
@@ -814,12 +887,19 @@
|
||||
"pressAndHoldToPlayVideo": "Tekan dan tahan untuk memutar video",
|
||||
"pressAndHoldToPlayVideoDetailed": "Tekan dan tahan gambar untuk memutar video",
|
||||
"downloadFailed": "Gagal mengunduh",
|
||||
"deduplicateFiles": "Hilangkan Duplikasi File",
|
||||
"deselectAll": "Batalkan semua pilihan",
|
||||
"reviewDeduplicateItems": "Silakan lihat dan hapus item yang merupakan duplikat.",
|
||||
"clubByCaptureTime": "Kelompokkan berdasarkan waktu pengambilan",
|
||||
"clubByFileName": "Kelompokkan berdasarkan nama berkas",
|
||||
"count": "Jumlah",
|
||||
"totalSize": "Ukuran total",
|
||||
"longpressOnAnItemToViewInFullscreen": "Tekan lama pada item untuk melihat dalam layar penuh",
|
||||
"decryptingVideo": "Mendekripsi video...",
|
||||
"authToViewYourMemories": "Harap autentikasi untuk melihat kenanganmu",
|
||||
"unlock": "Buka",
|
||||
"freeUpSpace": "Bersihkan ruang",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {Itu dapat dihapus dari perangkat untuk mengosongkan {formattedSize}} other {Itu dapat dihapus dari perangkat untuk mengosongkan {formattedSize}}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, other {{formattedNumber} file}} dalam album ini telah berhasil dicadangkan",
|
||||
"@filesBackedUpInAlbum": {
|
||||
"description": "Text to tell user how many files have been backed up in the album",
|
||||
@@ -850,11 +930,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"@freeUpSpaceSaving": {
|
||||
"description": "Text to tell user how much space they can free up by deleting items from the device"
|
||||
},
|
||||
"freeUpAccessPostDelete": "anda masih dapat mengakses {count, plural, =1 {itu} other {mereka}} di Ente selama anda memiliki langganan aktif",
|
||||
"@freeUpAccessPostDelete": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"example": "1",
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"freeUpAmount": "Bersihkan {sizeInMBorGB}",
|
||||
"thisEmailIsAlreadyInUse": "Email ini telah digunakan",
|
||||
"incorrectCode": "Kode salah",
|
||||
"authenticationFailedPleaseTryAgain": "Autentikasi gagal, silakan coba lagi",
|
||||
"verificationFailedPleaseTryAgain": "Verifikasi gagal, silakan coba lagi",
|
||||
"authenticating": "Autentikasi...",
|
||||
"authenticationSuccessful": "Autentikasi berhasil!",
|
||||
"incorrectRecoveryKey": "Kunci pemulihan salah",
|
||||
"theRecoveryKeyYouEnteredIsIncorrect": "Kunci pemulihan yang kamu masukkan salah",
|
||||
@@ -865,12 +958,35 @@
|
||||
"sorryTheCodeYouveEnteredIsIncorrect": "Maaf, kode yang kamu masukkan salah",
|
||||
"yourVerificationCodeHasExpired": "Kode verifikasi kamu telah kedaluwarsa",
|
||||
"emailChangedTo": "Email diubah menjadi {newEmail}",
|
||||
"verifying": "Memverifikasi...",
|
||||
"disablingTwofactorAuthentication": "Menonaktifkan autentikasi dua langkah...",
|
||||
"allMemoriesPreserved": "Semua kenangan terpelihara",
|
||||
"loadingGallery": "Memuat galeri...",
|
||||
"syncing": "Menyinkronkan...",
|
||||
"encryptingBackup": "Mengenkripsi cadangan...",
|
||||
"syncStopped": "Sinkronisasi terhenti",
|
||||
"syncProgress": "{completed}/{total} memori tersimpan",
|
||||
"uploadingMultipleMemories": "Menyimpan {count} memori...",
|
||||
"@uploadingMultipleMemories": {
|
||||
"description": "Text to tell user how many memories are being preserved",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"uploadingSingleMemory": "Menyimpan 1 memori...",
|
||||
"@syncProgress": {
|
||||
"description": "Text to tell user how many memories have been preserved",
|
||||
"placeholders": {
|
||||
"completed": {
|
||||
"type": "String"
|
||||
},
|
||||
"total": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"archiving": "Mengarsipkan...",
|
||||
"unarchiving": "Mengeluarkan dari arsip...",
|
||||
"successfullyArchived": "Berhasil diarsipkan",
|
||||
@@ -885,6 +1001,8 @@
|
||||
"empty": "Kosongkan",
|
||||
"couldNotFreeUpSpace": "Tidak dapat membersihkan ruang",
|
||||
"permanentlyDeleteFromDevice": "Hapus dari perangkat secara permanen?",
|
||||
"someOfTheFilesYouAreTryingToDeleteAre": "Beberapa file yang anda coba hapus hanya tersedia di perangkat anda dan tidak dapat dipulihkan jika dihapus",
|
||||
"theyWillBeDeletedFromAllAlbums": "Mereka akan dihapus dari semua album.",
|
||||
"someItemsAreInBothEnteAndYourDevice": "Sejumlah item tersimpan di Ente serta di perangkat ini.",
|
||||
"selectedItemsWillBeDeletedFromAllAlbumsAndMoved": "Item terpilih akan dihapus dari semua album dan dipindahkan ke sampah.",
|
||||
"theseItemsWillBeDeletedFromYourDevice": "Item ini akan dihapus dari perangkat ini.",
|
||||
@@ -894,12 +1012,17 @@
|
||||
"networkHostLookUpErr": "Tidak dapat terhubung dengan Ente, harap periksa pengaturan jaringan kamu dan hubungi dukungan jika masalah berlanjut.",
|
||||
"networkConnectionRefusedErr": "Tidak dapat terhubung dengan Ente, silakan coba lagi setelah beberapa saat. Jika masalah berlanjut, harap hubungi dukungan.",
|
||||
"cachedData": "Data cache",
|
||||
"clearCaches": "Bersihkan cache",
|
||||
"remoteImages": "Gambar jarak jauh",
|
||||
"remoteVideos": "Video jarak jauh",
|
||||
"remoteThumbnails": "Thumbnail jarak jauh",
|
||||
"pendingSync": "Sinkronisasi tertunda",
|
||||
"localGallery": "Galeri lokal",
|
||||
"todaysLogs": "Log hari ini",
|
||||
"viewLogs": "Lihat log",
|
||||
"logsDialogBody": "Ini akan mengirimkan log untuk membantu kami memperbaiki masalah anda. Harap diperhatikan bahwa nama file akan disertakan untuk membantu melacak masalah pada file tertentu.",
|
||||
"preparingLogs": "Menyiapkan log...",
|
||||
"emailYourLogs": "Kirim log anda melalui email",
|
||||
"pleaseSendTheLogsTo": "Silakan kirim log-nya ke \n{toEmail}",
|
||||
"copyEmailAddress": "Salin alamat email",
|
||||
"exportLogs": "Ekspor log",
|
||||
|
||||
@@ -1745,5 +1745,11 @@
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
@@ -1665,5 +1665,11 @@
|
||||
"moon": "月明かりの中",
|
||||
"onTheRoad": "再び道で",
|
||||
"food": "料理を楽しむ",
|
||||
"pets": "毛むくじゃらな仲間たち"
|
||||
"pets": "毛むくじゃらな仲間たち",
|
||||
"cLTitle1": "類似画像",
|
||||
"cLDesc1": "類似画像を検出する新しいML基盤システムを導入し、ライブラリをクリーンアップできます。設定 -> バックアップ -> 容量を空ける で利用可能",
|
||||
"cLTitle2": "動画ストリーミングの強化",
|
||||
"cLDesc2": "アプリから直接、動画のストリーム生成を手動でトリガーできるようになりました。また、動画のうち何パーセントがストリーミング用に処理されたかを表示する新しい動画ストリーミング設定画面も追加しました",
|
||||
"cLTitle3": "パフォーマンスの改善",
|
||||
"cLDesc3": "より良いキャッシュ使用とよりスムーズなスクロール体験を含む、複数の内部改善"
|
||||
}
|
||||
@@ -1776,14 +1776,6 @@
|
||||
"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ę",
|
||||
@@ -1818,5 +1810,16 @@
|
||||
"brushColor": "Teptuko spalva",
|
||||
"font": "Šriftas",
|
||||
"background": "Fonas",
|
||||
"align": "Lygiuoti"
|
||||
}
|
||||
"align": "Lygiuoti",
|
||||
"similarImages": "Panašūs vaizdai",
|
||||
"findSimilarImages": "Rasti panašų vaizdų",
|
||||
"noSimilarImagesFound": "Panašių vaizdų nerasta",
|
||||
"yourPhotosLookUnique": "Jūsų nuotraukos atrodo ypatingos",
|
||||
"selectionOptions": "Pasirinkimo parinktys",
|
||||
"deleteFiles": "Ištrinti failus",
|
||||
"areYouSureDeleteFiles": "Ar tikrai norite ištrinti šiuos failus?",
|
||||
"greatJob": "Puiku!",
|
||||
"size": "Dydis",
|
||||
"similarity": "Panašumas",
|
||||
"processingLocally": "Apdorojama vietoje"
|
||||
}
|
||||
|
||||
@@ -1772,5 +1772,11 @@
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
@@ -1736,5 +1736,11 @@
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
@@ -1776,14 +1776,6 @@
|
||||
"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ń",
|
||||
@@ -1827,5 +1819,11 @@
|
||||
"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"
|
||||
}
|
||||
@@ -1776,14 +1776,6 @@
|
||||
"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",
|
||||
@@ -1827,5 +1819,11 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -1776,14 +1776,6 @@
|
||||
"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",
|
||||
@@ -1827,5 +1819,11 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -1521,5 +1521,11 @@
|
||||
"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"
|
||||
"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ă"
|
||||
}
|
||||
@@ -1785,5 +1785,11 @@
|
||||
"analysis": "Анализ",
|
||||
"day": "День",
|
||||
"filter": "Фильтр",
|
||||
"font": "Шрифт"
|
||||
"font": "Шрифт",
|
||||
"cLTitle1": "Похожие изображения",
|
||||
"cLDesc1": "Мы внедряем новую систему на основе ML для обнаружения похожих изображений, с помощью которой вы можете очистить свою библиотеку. Доступно в Настройки->Резервная копия->Освободить место",
|
||||
"cLTitle2": "Улучшения видео стриминга",
|
||||
"cLDesc2": "Теперь вы можете вручную запустить генерацию потока для видео прямо из приложения. Мы также добавили новый экран настроек видео стриминга, который покажет вам, какой процент ваших видео был обработан для стриминга",
|
||||
"cLTitle3": "Улучшения производительности",
|
||||
"cLDesc3": "Множественные улучшения под капотом, включая лучшее использование кэша и более плавную прокрутку"
|
||||
}
|
||||
@@ -1776,5 +1776,11 @@
|
||||
"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."
|
||||
"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"
|
||||
}
|
||||
@@ -1509,5 +1509,11 @@
|
||||
},
|
||||
"legacyInvite": "{email} запросив вас стати довіреною особою",
|
||||
"authToManageLegacy": "Авторизуйтесь, щоби керувати довіреними контактами",
|
||||
"useDifferentPlayerInfo": "Виникли проблеми з відтворенням цього відео? Натисніть і утримуйте тут, щоб спробувати інший плеєр."
|
||||
"useDifferentPlayerInfo": "Виникли проблеми з відтворенням цього відео? Натисніть і утримуйте тут, щоб спробувати інший плеєр.",
|
||||
"cLTitle1": "Схожі зображення",
|
||||
"cLDesc1": "Ми впроваджуємо нову систему на основі ML для виявлення схожих зображень, за допомогою якої ви можете очистити свою бібліотеку. Доступно в Налаштування->Резервна копія->Звільнити місце",
|
||||
"cLTitle2": "Покращення відео стрімінгу",
|
||||
"cLDesc2": "Тепер ви можете вручну запустити генерацію потоку для відео прямо з додатку. Ми також додали новий екран налаштувань відео стрімінгу, який покаже вам, який відсоток ваших відео було оброблено для стрімінгу",
|
||||
"cLTitle3": "Покращення продуктивності",
|
||||
"cLDesc3": "Численні покращення під капотом, включаючи краще використання кешу та більш плавну прокрутку"
|
||||
}
|
||||
@@ -1776,14 +1776,6 @@
|
||||
"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",
|
||||
@@ -1827,5 +1819,123 @@
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"videosProcessed": "Video đã được xử lý",
|
||||
"totalVideos": "Tổng số video",
|
||||
"skippedVideos": "Video bị bỏ qua",
|
||||
"videoStreamingDescriptionLine1": "Phát video trên bất kỳ thiết bị.",
|
||||
"videoStreamingDescriptionLine2": "Bật để xử lý luồng phát video trên thiết bị này.",
|
||||
"videoStreamingNote": "Thiết bị này chỉ xử lý các video từ 60 ngày trở xuống và có thời lượng dưới 1 phút. Đối với các video cũ hơn/dài hơn, hãy bật tính năng phát trực tuyến trong ứng dụng máy tính để bàn.",
|
||||
"createStream": "Tạo phát trực tiếp",
|
||||
"recreateStream": "Tạo lại phát trực tiếp",
|
||||
"addedToStreamCreationQueue": "Đã thêm vào hàng đợi tạo luồng",
|
||||
"addedToStreamRecreationQueue": "Đã thêm vào hàng đợi tạo lại luồng",
|
||||
"videoPreviewAlreadyExists": "Bản xem trước video đã tồn tại",
|
||||
"videoAlreadyInQueue": "Tệp video đã có trong hàng đợi",
|
||||
"addedToQueue": "Đã thêm vào hàng đợi",
|
||||
"creatingStream": "Đang tạo luồng",
|
||||
"similarImages": "Ảnh giống nhau",
|
||||
"findSimilarImages": "Tìm ảnh giống nhau",
|
||||
"noSimilarImagesFound": "Không tìm thấy ảnh giống nhau",
|
||||
"yourPhotosLookUnique": "Ảnh của bạn trông độc đáo",
|
||||
"similarGroupsFound": "{count, plural, =1{{count} nhóm được tìm thấy} other{{count} nhóm được tìm thấy}}",
|
||||
"@similarGroupsFound": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reviewAndRemoveSimilarImages": "Xem lại và xóa ảnh giống nhau",
|
||||
"deletePhotosWithSize": "Xóa {count} ảnh ({size})",
|
||||
"@deletePhotosWithSize": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "String"
|
||||
},
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionOptions": "Tùy chọn lựa chọn",
|
||||
"selectExactWithCount": "Giống nhau hoàn toàn ({count})",
|
||||
"@selectExactWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectExact": "Chọn chính xác",
|
||||
"selectSimilarWithCount": "Giống nhau một phần ({count})",
|
||||
"@selectSimilarWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectSimilar": "Chọn giống nhau",
|
||||
"selectAllWithCount": "Tất cả giống nhau ({count})",
|
||||
"@selectAllWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectSimilarImagesTitle": "Chọn những ảnh giống nhau",
|
||||
"chooseSimilarImagesToSelect": "Chọn ảnh dựa trên sự tương đồng thị giác",
|
||||
"clearSelection": "Bỏ chọn",
|
||||
"similarImagesCount": "{count} ảnh giống nhau",
|
||||
"@similarImagesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteWithCount": "Xóa ({count})",
|
||||
"@deleteWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteFiles": "Xóa các tệp",
|
||||
"areYouSureDeleteFiles": "Bạn có chắc muốn xóa các tệp này?",
|
||||
"greatJob": "Tốt lắm!",
|
||||
"cleanedUpSimilarImages": "Bạn tiết kiệm được {size}",
|
||||
"@cleanedUpSimilarImages": {
|
||||
"placeholders": {
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"size": "Dung lượng",
|
||||
"similarity": "Sự giống nhau",
|
||||
"analyzingPhotosLocally": "Phân tích ảnh của bạn trên thiết bị...",
|
||||
"lookingForVisualSimilarities": "Tìm theo sự tương đồng thị giác...",
|
||||
"comparingImageDetails": "So sánh các đặc điểm ảnh...",
|
||||
"findingSimilarImages": "Tìm các ảnh giống nhau...",
|
||||
"almostDone": "Sắp xong...",
|
||||
"processingLocally": "Đang xử lý cục bộ",
|
||||
"useMLToFindSimilarImages": "Xem lại và xóa những ảnh có vẻ giống nhau.",
|
||||
"all": "Tất cả",
|
||||
"similar": "Giống nhau",
|
||||
"identical": "Giống hệt nhau",
|
||||
"nothingHereTryAnotherFilter": "Không thấy gì, hãy thử thay đổi bộ lọc! 👀",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -1776,14 +1776,6 @@
|
||||
"same": "相同",
|
||||
"different": "不同",
|
||||
"sameperson": "是同一个人?",
|
||||
"cLTitle1": "高级图像编辑器",
|
||||
"cLDesc1": "我们正在发布一款全新且高级的图像编辑器,新增更多裁剪框架、快速编辑的滤镜预设,以及包括饱和度、对比度、亮度、色温等在内的精细调整选项。新的编辑器还支持在照片上绘制和添加表情符号作为贴纸。",
|
||||
"cLTitle2": "智能相册",
|
||||
"cLDesc2": "您现在可以将所选人物的照片自动添加到任何相册。只需进入相册,从溢出菜单中选择“自动添加人物”。如果与共享相册一起使用,您可以零点击分享照片。",
|
||||
"cLTitle3": "改进的相册",
|
||||
"cLDesc3": "我们新增了按周、月、年对图库进行分组的功能。您现在可以通过这些新的分组选项以及自定义网格,定制图库的外观,完全按照您的喜好进行设置",
|
||||
"cLTitle4": "更快滚动",
|
||||
"cLDesc4": "除了多项后台改进以提升图库滚动体验外,我们还重新设计了滚动条,添加了标记功能,让您可以快速跳转到时间轴上的不同位置。",
|
||||
"indexingPausedStatusDescription": "索引已暂停。待设备准备就绪后,索引将自动恢复。当设备的电池电量、电池健康度和温度状态处于健康范围内时,设备即被视为准备就绪。",
|
||||
"thisWeek": "本周",
|
||||
"lastWeek": "上周",
|
||||
@@ -1827,5 +1819,117 @@
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"videosProcessed": "视频已处理",
|
||||
"totalVideos": "视频总数",
|
||||
"skippedVideos": "已跳过的视频",
|
||||
"videoStreamingDescriptionLine1": "在任何设备上立即播放视频。",
|
||||
"videoStreamingDescriptionLine2": "启用以处理此设备上的视频流。",
|
||||
"videoStreamingNote": "此设备仅处理过去 60 天内时长不超过 1 分钟的视频。对于更早/更长的视频,请在桌面应用中启用流式传输。",
|
||||
"createStream": "创建流",
|
||||
"recreateStream": "重建流",
|
||||
"addedToStreamCreationQueue": "已添加到流创建队列",
|
||||
"addedToStreamRecreationQueue": "已添加到流重建队列",
|
||||
"videoPreviewAlreadyExists": "视频预览已存在",
|
||||
"videoAlreadyInQueue": "视频文件已存在于队列中",
|
||||
"addedToQueue": "已添加至队列",
|
||||
"creatingStream": "正在创建流",
|
||||
"similarImages": "相似图片",
|
||||
"findSimilarImages": "查找相似图片",
|
||||
"noSimilarImagesFound": "未找到相似图片",
|
||||
"yourPhotosLookUnique": "您的照片看起来很独特",
|
||||
"similarGroupsFound": "{count, plural, =1{{count} 组已找到} other{{count} 组已找到}}",
|
||||
"@similarGroupsFound": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reviewAndRemoveSimilarImages": "查看并删除相似图片",
|
||||
"deletePhotosWithSize": "删除 {count} 张照片 ({size})",
|
||||
"@deletePhotosWithSize": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "String"
|
||||
},
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionOptions": "选择选项",
|
||||
"selectExactWithCount": "完全相似 ({count})",
|
||||
"@selectExactWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectExact": "精确选择",
|
||||
"selectSimilarWithCount": "部分相似 ({count})",
|
||||
"@selectSimilarWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectSimilar": "选择相似项",
|
||||
"selectAllWithCount": "所有相似项 ({count})",
|
||||
"@selectAllWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectSimilarImagesTitle": "选择所有相似图片",
|
||||
"chooseSimilarImagesToSelect": "根据视觉相似性选择图像",
|
||||
"clearSelection": "清除选择",
|
||||
"similarImagesCount": "{count} 张相似图片",
|
||||
"@similarImagesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteWithCount": "删除 ({count}) 项",
|
||||
"@deleteWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteFiles": "删除文件",
|
||||
"areYouSureDeleteFiles": "您确定要删除这些文件吗?",
|
||||
"greatJob": "做得好!",
|
||||
"cleanedUpSimilarImages": "您已释放了 {size} 的空间",
|
||||
"@cleanedUpSimilarImages": {
|
||||
"placeholders": {
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"size": "大小",
|
||||
"similarity": "相似度",
|
||||
"processingLocally": "正在本地处理",
|
||||
"useMLToFindSimilarImages": "审查并删除看起来彼此相似的图像。",
|
||||
"all": "全部",
|
||||
"similar": "相似的",
|
||||
"identical": "完全相同",
|
||||
"nothingHereTryAnotherFilter": "此处无内容,请尝试其他过滤器!👀",
|
||||
"related": "相关",
|
||||
"hoorayyyy": "耶~~!",
|
||||
"nothingToTidyUpHere": "这里没什么可清理的",
|
||||
"cLTitle1": "相似图像",
|
||||
"cLDesc1": "我们正在推出一个基于机器学习的新系统来检测相似图像,您可以用它来清理您的图库。在 设置 -> 备份 -> 释放空间 中可用",
|
||||
"cLTitle2": "视频流媒体增强",
|
||||
"cLDesc2": "您现在可以直接从应用程序手动触发视频的流生成。我们还添加了一个新的视频流设置屏幕,它将显示您的视频中有百分之几已被处理用于流媒体播放",
|
||||
"cLTitle3": "性能改进",
|
||||
"cLDesc3": "多个底层改进,包括更好的缓存使用和更流畅的滚动体验"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,29 @@ import "package:flutter/widgets.dart";
|
||||
import 'package:photos/generated/intl/app_localizations.dart';
|
||||
import "package:shared_preferences/shared_preferences.dart";
|
||||
|
||||
// list of locales which are enabled for photos app.
|
||||
// Add more language to the list only when at least 90% of the strings are
|
||||
// translated in the corresponding language.
|
||||
const List<Locale> appSupportedLocales = <Locale>[
|
||||
Locale('en'),
|
||||
Locale('es'),
|
||||
Locale('de'),
|
||||
Locale('fr'),
|
||||
Locale('it'),
|
||||
Locale('ja'),
|
||||
Locale("nl"),
|
||||
Locale("no"),
|
||||
Locale("pl"),
|
||||
Locale("pt", "BR"),
|
||||
Locale('pt', 'PT'),
|
||||
Locale("ro"),
|
||||
Locale("ru"),
|
||||
Locale("tr"),
|
||||
Locale("uk"),
|
||||
Locale("vi"),
|
||||
Locale("zh", "CN"),
|
||||
];
|
||||
|
||||
extension AppLocalizationsX on BuildContext {
|
||||
AppLocalizations get l10n => AppLocalizations.of(this);
|
||||
}
|
||||
@@ -12,12 +35,12 @@ Locale? autoDetectedLocale;
|
||||
Locale localResolutionCallBack(deviceLocales, supportedLocales) {
|
||||
_onDeviceLocales = deviceLocales;
|
||||
final Set<String> languageSupport = {};
|
||||
for (Locale supportedLocale in AppLocalizations.supportedLocales) {
|
||||
for (Locale supportedLocale in appSupportedLocales) {
|
||||
languageSupport.add(supportedLocale.languageCode);
|
||||
}
|
||||
for (Locale locale in deviceLocales) {
|
||||
// check if exact local is supported, if yes, return it
|
||||
if (AppLocalizations.supportedLocales.contains(locale)) {
|
||||
if (appSupportedLocales.contains(locale)) {
|
||||
autoDetectedLocale = locale;
|
||||
return locale;
|
||||
}
|
||||
@@ -67,7 +90,7 @@ Future<Locale?> getLocale({
|
||||
} else {
|
||||
savedLocale = Locale(savedValue);
|
||||
}
|
||||
if (AppLocalizations.supportedLocales.contains(savedLocale)) {
|
||||
if (appSupportedLocales.contains(savedLocale)) {
|
||||
return savedLocale;
|
||||
}
|
||||
}
|
||||
@@ -81,7 +104,7 @@ Future<Locale?> getLocale({
|
||||
}
|
||||
|
||||
Future<void> setLocale(Locale locale) async {
|
||||
if (!AppLocalizations.supportedLocales.contains(locale)) {
|
||||
if (!appSupportedLocales.contains(locale)) {
|
||||
throw Exception('Locale $locale is not supported by the app');
|
||||
}
|
||||
final StringBuffer out = StringBuffer(locale.languageCode);
|
||||
|
||||
@@ -13,7 +13,8 @@ class Collection {
|
||||
final User owner;
|
||||
final String encryptedKey;
|
||||
final String? keyDecryptionNonce;
|
||||
@Deprecated("Use collectionName instead")
|
||||
|
||||
/// WARNING: use collectionName instead of name! Name is deprecated but can't be removed because of old accounts.
|
||||
String? name;
|
||||
|
||||
// encryptedName & nameDecryptionNonce will be null for collections
|
||||
|
||||
@@ -32,7 +32,14 @@ class ComputeController {
|
||||
bool _isDeviceHealthy = true;
|
||||
bool _isUserInteracting = true;
|
||||
bool _canRunCompute = false;
|
||||
|
||||
/// If true, user interaction is ignored and compute tasks can run regardless of user activity.
|
||||
bool interactionOverride = false;
|
||||
|
||||
/// If true, compute tasks are paused regardless of device health or user activity.
|
||||
bool get computeBlocked => _computeBlocks.isNotEmpty;
|
||||
final Set<String> _computeBlocks = {};
|
||||
|
||||
late Timer _userInteractionTimer;
|
||||
|
||||
ComputeRunState _currentRunState = ComputeRunState.idle;
|
||||
@@ -42,41 +49,57 @@ class ComputeController {
|
||||
|
||||
ComputeController() {
|
||||
_logger.info('ComputeController constructor');
|
||||
init();
|
||||
_logger.info('init done ');
|
||||
}
|
||||
|
||||
// Directly assign the values + Attach listener for compute controller
|
||||
Future<void> init() async {
|
||||
// Interaction Timer
|
||||
_startInteractionTimer(kDefaultInteractionTimeout);
|
||||
|
||||
// Thermal related
|
||||
_onThermalStateUpdate(await _thermal.thermalStatus);
|
||||
_thermal.onThermalStatusChanged.listen((ThermalStatus thermalState) {
|
||||
_onThermalStateUpdate(thermalState);
|
||||
});
|
||||
|
||||
// Battery State
|
||||
if (Platform.isIOS) {
|
||||
if (kDebugMode) {
|
||||
_logger.info(
|
||||
_logger.fine(
|
||||
"iOS battery info stream is not available in simulator, disabling in debug mode",
|
||||
);
|
||||
// if you need to test on physical device, uncomment this check
|
||||
return;
|
||||
} else {
|
||||
// Update Battery state for iOS
|
||||
_oniOSBatteryStateUpdate(await BatteryInfoPlugin().iosBatteryInfo);
|
||||
BatteryInfoPlugin()
|
||||
.iosBatteryInfoStream
|
||||
.listen((IosBatteryInfo? batteryInfo) {
|
||||
_oniOSBatteryStateUpdate(batteryInfo);
|
||||
});
|
||||
}
|
||||
BatteryInfoPlugin()
|
||||
.iosBatteryInfoStream
|
||||
.listen((IosBatteryInfo? batteryInfo) {
|
||||
_oniOSBatteryStateUpdate(batteryInfo);
|
||||
});
|
||||
}
|
||||
if (Platform.isAndroid) {
|
||||
} else if (Platform.isAndroid) {
|
||||
// Update Battery state for Android
|
||||
_onAndroidBatteryStateUpdate(
|
||||
await BatteryInfoPlugin().androidBatteryInfo,
|
||||
);
|
||||
BatteryInfoPlugin()
|
||||
.androidBatteryInfoStream
|
||||
.listen((AndroidBatteryInfo? batteryInfo) {
|
||||
_onAndroidBatteryStateUpdate(batteryInfo);
|
||||
});
|
||||
}
|
||||
_thermal.onThermalStatusChanged.listen((ThermalStatus thermalState) {
|
||||
_onThermalStateUpdate(thermalState);
|
||||
});
|
||||
_logger.info('init done ');
|
||||
}
|
||||
|
||||
bool requestCompute({
|
||||
bool ml = false,
|
||||
bool stream = false,
|
||||
bool bypassInteractionCheck = false,
|
||||
bool bypassMLWaiting = false,
|
||||
}) {
|
||||
_logger.info(
|
||||
"Requesting compute: ml: $ml, stream: $stream, bypassInteraction: $bypassInteractionCheck",
|
||||
"Requesting compute: ml: $ml, stream: $stream, bypassInteraction: $bypassInteractionCheck, bypassMLWaiting: $bypassMLWaiting",
|
||||
);
|
||||
if (!_isDeviceHealthy) {
|
||||
_logger.info("Device not healthy, denying request.");
|
||||
@@ -86,11 +109,15 @@ class ComputeController {
|
||||
_logger.info("User interacting, denying request.");
|
||||
return false;
|
||||
}
|
||||
if (computeBlocked) {
|
||||
_logger.info("Compute is blocked by: $_computeBlocks, denying request.");
|
||||
return false;
|
||||
}
|
||||
bool result = false;
|
||||
if (ml) {
|
||||
result = _requestML();
|
||||
} else if (stream) {
|
||||
result = _requestStream();
|
||||
result = _requestStream(bypassMLWaiting);
|
||||
} else {
|
||||
_logger.severe("No compute request specified, denying request.");
|
||||
}
|
||||
@@ -117,14 +144,15 @@ class ComputeController {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _requestStream() {
|
||||
if (_currentRunState == ComputeRunState.idle && !_waitingToRunML) {
|
||||
bool _requestStream([bool bypassMLWaiting = false]) {
|
||||
if (_currentRunState == ComputeRunState.idle &&
|
||||
(bypassMLWaiting || !_waitingToRunML)) {
|
||||
_logger.info("Stream request granted");
|
||||
_currentRunState = ComputeRunState.generatingStream;
|
||||
return true;
|
||||
}
|
||||
_logger.info(
|
||||
"Stream request denied, current state: $_currentRunState, wants to run ML: $_waitingToRunML",
|
||||
"Stream request denied, current state: $_currentRunState, wants to run ML: $_waitingToRunML, bypassMLWaiting: $bypassMLWaiting",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -165,12 +193,25 @@ class ComputeController {
|
||||
_fireControlEvent();
|
||||
}
|
||||
|
||||
void blockCompute({required String blocker}) {
|
||||
_computeBlocks.add(blocker);
|
||||
_logger.info("Forcing to pauze compute due to: $blocker");
|
||||
_fireControlEvent();
|
||||
}
|
||||
|
||||
void unblockCompute({required String blocker}) {
|
||||
_computeBlocks.remove(blocker);
|
||||
_logger.info("removed blocker: $blocker, now blocked: $computeBlocked");
|
||||
_fireControlEvent();
|
||||
}
|
||||
|
||||
void _fireControlEvent() {
|
||||
final shouldRunCompute = _isDeviceHealthy && _canRunGivenUserInteraction();
|
||||
final shouldRunCompute =
|
||||
_isDeviceHealthy && _canRunGivenUserInteraction() && !computeBlocked;
|
||||
if (shouldRunCompute != _canRunCompute) {
|
||||
_canRunCompute = shouldRunCompute;
|
||||
_logger.info(
|
||||
"Firing event: $shouldRunCompute (device health: $_isDeviceHealthy, user interaction: $_isUserInteracting, mlInteractionOverride: $interactionOverride)",
|
||||
"Firing event: $shouldRunCompute (device health: $_isDeviceHealthy, user interaction: $_isUserInteracting, mlInteractionOverride: $interactionOverride, blockers: $_computeBlocks)",
|
||||
);
|
||||
Bus.instance.fire(ComputeControlEvent(shouldRunCompute));
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "dart:io" show File;
|
||||
import "dart:math" show max;
|
||||
|
||||
import "package:flutter/foundation.dart" show kDebugMode;
|
||||
@@ -10,6 +11,7 @@ import "package:photos/extensions/stop_watch.dart";
|
||||
import "package:photos/models/file/extensions/file_props.dart";
|
||||
import 'package:photos/models/file/file.dart';
|
||||
import "package:photos/models/similar_files.dart";
|
||||
import "package:photos/services/favorites_service.dart";
|
||||
import "package:photos/services/machine_learning/ml_computer.dart";
|
||||
import "package:photos/services/machine_learning/ml_result.dart";
|
||||
import "package:photos/services/search_service.dart";
|
||||
@@ -257,6 +259,11 @@ class SimilarImagesService {
|
||||
group.addFile(newFile);
|
||||
group.furthestDistance = max(group.furthestDistance, distance);
|
||||
group.files.sort((a, b) {
|
||||
if (FavoritesService.instance.isFavoriteCache(a)) {
|
||||
return -1;
|
||||
} else if (FavoritesService.instance.isFavoriteCache(b)) {
|
||||
return 1;
|
||||
}
|
||||
final sizeComparison =
|
||||
(b.fileSize ?? 0).compareTo(a.fileSize ?? 0);
|
||||
if (sizeComparison != 0) return sizeComparison;
|
||||
@@ -307,6 +314,11 @@ class SimilarImagesService {
|
||||
similarNewFiles.add(newFile);
|
||||
alreadyUsedNewFiles.add(newFileID);
|
||||
similarNewFiles.sort((a, b) {
|
||||
if (FavoritesService.instance.isFavoriteCache(a)) {
|
||||
return -1;
|
||||
} else if (FavoritesService.instance.isFavoriteCache(b)) {
|
||||
return 1;
|
||||
}
|
||||
final sizeComparison = (b.fileSize ?? 0).compareTo(a.fileSize ?? 0);
|
||||
if (sizeComparison != 0) return sizeComparison;
|
||||
return a.displayName.compareTo(b.displayName);
|
||||
@@ -381,6 +393,11 @@ class SimilarImagesService {
|
||||
}
|
||||
// show highest quality files first
|
||||
similarFilesList.sort((a, b) {
|
||||
if (FavoritesService.instance.isFavoriteCache(a)) {
|
||||
return -1;
|
||||
} else if (FavoritesService.instance.isFavoriteCache(b)) {
|
||||
return 1;
|
||||
}
|
||||
final sizeComparison = (b.fileSize ?? 0).compareTo(a.fileSize ?? 0);
|
||||
if (sizeComparison != 0) return sizeComparison;
|
||||
return a.displayName.compareTo(b.displayName);
|
||||
@@ -434,6 +451,20 @@ class SimilarImagesService {
|
||||
);
|
||||
return cache;
|
||||
}
|
||||
|
||||
Future<void> clearCache() async {
|
||||
try {
|
||||
final cachePath = await _getCachePath();
|
||||
final file = File(cachePath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
_logger.info("Cleared similar files cache at $cachePath");
|
||||
}
|
||||
} catch (e, s) {
|
||||
_logger.severe("Error clearing similar files cache", e, s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool setsAreEqual(Set<String> set1, Set<String> set2) {
|
||||
|
||||
@@ -14,7 +14,7 @@ import 'package:url_launcher/url_launcher_string.dart';
|
||||
class UpdateService {
|
||||
static const kUpdateAvailableShownTimeKey = "update_available_shown_time_key";
|
||||
static const changeLogVersionKey = "update_change_log_key";
|
||||
static const currentChangeLogVersion = 31;
|
||||
static const currentChangeLogVersion = 36;
|
||||
|
||||
LatestVersionInfo? _latestVersion;
|
||||
final _logger = Logger("UpdateService");
|
||||
|
||||
@@ -125,6 +125,10 @@ class VideoPreviewService {
|
||||
file.uploadedFileID!,
|
||||
);
|
||||
if (alreadyInQueue) {
|
||||
// File is already queued, but trigger processing in case it was stalled
|
||||
if (uploadingFileId < 0) {
|
||||
queueFiles(duration: Duration.zero, isManual: true, forceProcess: true);
|
||||
}
|
||||
return false; // Indicates file was already in queue
|
||||
}
|
||||
|
||||
@@ -153,7 +157,25 @@ class VideoPreviewService {
|
||||
|
||||
bool isCurrentlyProcessing(int? uploadedFileID) {
|
||||
if (uploadedFileID == null) return false;
|
||||
return uploadingFileId == uploadedFileID;
|
||||
|
||||
// Also check if file is in queue or other processing states
|
||||
final item = _items[uploadedFileID];
|
||||
if (item != null) {
|
||||
switch (item.status) {
|
||||
case PreviewItemStatus.inQueue:
|
||||
case PreviewItemStatus.compressing:
|
||||
case PreviewItemStatus.uploading:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
PreviewItemStatus? getProcessingStatus(int uploadedFileID) {
|
||||
return _items[uploadedFileID]?.status;
|
||||
}
|
||||
|
||||
Future<bool> _isRecreateOperation(EnteFile file) async {
|
||||
@@ -252,15 +274,20 @@ class VideoPreviewService {
|
||||
|
||||
Future<void> chunkAndUploadVideo(
|
||||
BuildContext? ctx,
|
||||
EnteFile enteFile, [
|
||||
EnteFile enteFile, {
|
||||
/// Indicates this function is an continuation of a chunking thread
|
||||
bool continuation = false,
|
||||
// not used currently
|
||||
bool forceUpload = false,
|
||||
bool isManual = false,
|
||||
]) async {
|
||||
}) async {
|
||||
final bool isManual =
|
||||
await uploadLocksDB.isInStreamQueue(enteFile.uploadedFileID!);
|
||||
final canStream = _isPermissionGranted();
|
||||
if (!canStream) {
|
||||
_logger.info(
|
||||
"Pause preview due to disabledSteaming($isVideoStreamingEnabled) or computeController permission) - isManual: $isManual",
|
||||
);
|
||||
computeController.releaseCompute(stream: true);
|
||||
if (isVideoStreamingEnabled) _logger.info("No permission to run compute");
|
||||
clearQueue();
|
||||
return;
|
||||
@@ -307,7 +334,7 @@ class VideoPreviewService {
|
||||
}
|
||||
|
||||
// check if there is already a preview in processing
|
||||
if (uploadingFileId >= 0) {
|
||||
if (!continuation && uploadingFileId >= 0) {
|
||||
if (uploadingFileId == enteFile.uploadedFileID) return;
|
||||
|
||||
_items[enteFile.uploadedFileID!] = PreviewItem(
|
||||
@@ -558,21 +585,26 @@ class VideoPreviewService {
|
||||
_removeFile(enteFile);
|
||||
_removeFromLocks(enteFile).ignore();
|
||||
}
|
||||
// reset uploading status if this was getting processed
|
||||
if (uploadingFileId == enteFile.uploadedFileID!) {
|
||||
uploadingFileId = -1;
|
||||
}
|
||||
_logger.info(
|
||||
"[chunk] Processing ${_items.length} items for streaming, $error",
|
||||
);
|
||||
// process next file
|
||||
if (fileQueue.isNotEmpty) {
|
||||
// process next file
|
||||
_logger.info(
|
||||
"[chunk] Processing ${_items.length} items for streaming, $error",
|
||||
);
|
||||
final entry = fileQueue.entries.first;
|
||||
final file = entry.value;
|
||||
fileQueue.remove(entry.key);
|
||||
await chunkAndUploadVideo(ctx, file);
|
||||
await chunkAndUploadVideo(
|
||||
ctx,
|
||||
file,
|
||||
continuation: true,
|
||||
);
|
||||
} else {
|
||||
_logger.info(
|
||||
"[chunk] Nothing to process releasing compute, $error",
|
||||
);
|
||||
computeController.releaseCompute(stream: true);
|
||||
|
||||
uploadingFileId = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -983,8 +1015,9 @@ class VideoPreviewService {
|
||||
}
|
||||
|
||||
// generate stream for all files after cutoff date
|
||||
Future<void> _putFilesForPreviewCreation() async {
|
||||
if (!isVideoStreamingEnabled || !await canUseHighBandwidth()) return;
|
||||
// returns false if it fails to launch chuncking function
|
||||
Future<bool> _putFilesForPreviewCreation() async {
|
||||
if (!isVideoStreamingEnabled || !await canUseHighBandwidth()) return false;
|
||||
|
||||
Map<int, String> failureFiles = {};
|
||||
Map<int, String> manualQueueFiles = {};
|
||||
@@ -1120,7 +1153,7 @@ class VideoPreviewService {
|
||||
final totalFiles = fileQueue.length;
|
||||
if (totalFiles == 0) {
|
||||
_logger.info("[init] No preview to cache");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.info(
|
||||
@@ -1132,6 +1165,7 @@ class VideoPreviewService {
|
||||
final file = entry.value;
|
||||
fileQueue.remove(entry.key);
|
||||
chunkAndUploadVideo(null, file).ignore();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _allowStream() {
|
||||
@@ -1144,26 +1178,34 @@ class VideoPreviewService {
|
||||
computeController.requestCompute(
|
||||
stream: true,
|
||||
bypassInteractionCheck: true,
|
||||
bypassMLWaiting: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// To check if it's enabled, device is healthy and running streaming
|
||||
bool _isPermissionGranted() {
|
||||
return isVideoStreamingEnabled &&
|
||||
computeController.computeState == ComputeRunState.generatingStream;
|
||||
computeController.computeState == ComputeRunState.generatingStream &&
|
||||
computeController.isDeviceHealthy;
|
||||
}
|
||||
|
||||
void queueFiles({
|
||||
Duration duration = const Duration(seconds: 5),
|
||||
bool isManual = false,
|
||||
bool forceProcess = false,
|
||||
}) {
|
||||
Future.delayed(duration, () async {
|
||||
if (_hasQueuedFile) return;
|
||||
if (_hasQueuedFile && !forceProcess) return;
|
||||
|
||||
final isStreamAllowed = isManual ? _allowManualStream() : _allowStream();
|
||||
if (!isStreamAllowed) return;
|
||||
|
||||
await _ensurePreviewIdsInitialized();
|
||||
await _putFilesForPreviewCreation();
|
||||
final result = await _putFilesForPreviewCreation();
|
||||
// Cannot proceed to stream generation, would have to release compute ASAP
|
||||
if (!result) {
|
||||
computeController.releaseCompute(stream: true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,12 +276,12 @@ class _CollectionActionSheetState extends State<CollectionActionSheet> {
|
||||
isDisabled: _selectedCollections.isEmpty,
|
||||
onTap: () async {
|
||||
if (widget.selectedPeople != null) {
|
||||
final ProgressDialog? dialog = createProgressDialog(
|
||||
final ProgressDialog dialog = createProgressDialog(
|
||||
context,
|
||||
AppLocalizations.of(context).uploadingFilesToAlbum,
|
||||
isDismissible: true,
|
||||
);
|
||||
await dialog?.show();
|
||||
await dialog.show();
|
||||
for (final collection in _selectedCollections) {
|
||||
try {
|
||||
await smartAlbumsService.addPeopleToSmartAlbum(
|
||||
@@ -297,7 +297,7 @@ class _CollectionActionSheetState extends State<CollectionActionSheet> {
|
||||
}
|
||||
}
|
||||
unawaited(smartAlbumsService.syncSmartAlbums());
|
||||
await dialog?.hide();
|
||||
await dialog.hide();
|
||||
return;
|
||||
}
|
||||
final CollectionActions collectionActions =
|
||||
|
||||
@@ -35,9 +35,8 @@ class TextInputWidget extends StatefulWidget {
|
||||
final bool popNavAfterSubmission;
|
||||
final bool shouldSurfaceExecutionStates;
|
||||
final TextCapitalization? textCapitalization;
|
||||
@Deprecated(
|
||||
"Do not use this widget for password input. Create a separate PasswordInputWidget. This widget is becoming bloated and hard to maintain, so will create a PasswordInputWidget and remove this field from this widget in future",
|
||||
)
|
||||
|
||||
/// WARNING: Do not use this widget for password input. Create a separate PasswordInputWidget. This widget is becoming bloated and hard to maintain, so will create a PasswordInputWidget and remove this field from this widget in future
|
||||
final bool isPasswordInput;
|
||||
|
||||
///Clear comes in the form of a suffix icon. It is unrelated to onCancel.
|
||||
|
||||
@@ -94,7 +94,7 @@ class _LandingPageWidgetState extends State<LandingPageWidget> {
|
||||
routeToPage(
|
||||
context,
|
||||
LanguageSelectorPage(
|
||||
AppLocalizations.supportedLocales,
|
||||
appSupportedLocales,
|
||||
(locale) async {
|
||||
await setLocale(locale);
|
||||
EnteApp.setLocale(context, locale);
|
||||
|
||||
@@ -77,7 +77,7 @@ class _ChangeLogPageState extends State<ChangeLogPage> {
|
||||
ButtonWidget(
|
||||
buttonType: ButtonType.trailingIconSecondary,
|
||||
buttonSize: ButtonSize.large,
|
||||
labelText: AppLocalizations.of(context).rateTheApp,
|
||||
labelText: AppLocalizations.of(context).rateUs,
|
||||
icon: Icons.favorite_rounded,
|
||||
iconColor: enteColorScheme.primary500,
|
||||
onTap: () async {
|
||||
@@ -112,12 +112,7 @@ class _ChangeLogPageState extends State<ChangeLogPage> {
|
||||
context.l10n.cLTitle3,
|
||||
context.l10n.cLDesc3,
|
||||
),
|
||||
ChangeLogEntry(
|
||||
context.l10n.cLTitle4,
|
||||
context.l10n.cLDesc4,
|
||||
),
|
||||
]);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: Scrollbar(
|
||||
|
||||
@@ -251,9 +251,12 @@ class _FreeUpSpaceOptionsScreenState extends State<FreeUpSpaceOptionsScreen> {
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuSectionDescriptionWidget(
|
||||
content: AppLocalizations.of(context)
|
||||
.viewLargeFilesDesc,
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: MenuSectionDescriptionWidget(
|
||||
content: AppLocalizations.of(context)
|
||||
.viewLargeFilesDesc,
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 24,
|
||||
|
||||
@@ -9,12 +9,20 @@ import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/components/captioned_text_widget.dart';
|
||||
import 'package:photos/ui/components/expandable_menu_item_widget.dart';
|
||||
import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart';
|
||||
import 'package:photos/ui/components/toggle_switch_widget.dart';
|
||||
import 'package:photos/ui/notification/toast.dart';
|
||||
import 'package:photos/ui/settings/common_settings.dart';
|
||||
import 'package:photos/ui/settings/debug/local_thumbnail_config_screen.dart';
|
||||
import 'package:photos/utils/navigation_util.dart';
|
||||
|
||||
class DebugSectionWidget extends StatelessWidget {
|
||||
class DebugSectionWidget extends StatefulWidget {
|
||||
const DebugSectionWidget({super.key});
|
||||
|
||||
@override
|
||||
State<DebugSectionWidget> createState() => _DebugSectionWidgetState();
|
||||
}
|
||||
|
||||
class _DebugSectionWidgetState extends State<DebugSectionWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ExpandableMenuItemWidget(
|
||||
@@ -27,6 +35,43 @@ class DebugSectionWidget extends StatelessWidget {
|
||||
Widget _getSectionOptions(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
sectionOptionSpacing,
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: const CaptionedTextWidget(
|
||||
title: "Show local ID over thumbnails",
|
||||
),
|
||||
pressedColor: getEnteColorScheme(context).fillFaint,
|
||||
trailingWidget: ToggleSwitchWidget(
|
||||
value: () => localSettings.showLocalIDOverThumbnails,
|
||||
onChanged: () async {
|
||||
await localSettings.setShowLocalIDOverThumbnails(
|
||||
!localSettings.showLocalIDOverThumbnails,
|
||||
);
|
||||
setState(() {});
|
||||
showShortToast(
|
||||
context,
|
||||
localSettings.showLocalIDOverThumbnails
|
||||
? "Local IDs will be shown. Restart app."
|
||||
: "Local IDs hidden. Restart app.",
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
sectionOptionSpacing,
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: const CaptionedTextWidget(
|
||||
title: "Local thumbnail queue config",
|
||||
),
|
||||
pressedColor: getEnteColorScheme(context).fillFaint,
|
||||
trailingIcon: Icons.chevron_right_outlined,
|
||||
trailingIconIsMuted: true,
|
||||
onTap: () async {
|
||||
await routeToPage(
|
||||
context,
|
||||
const LocalThumbnailConfigScreen(),
|
||||
);
|
||||
},
|
||||
),
|
||||
sectionOptionSpacing,
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: const CaptionedTextWidget(
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photos/service_locator.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/components/title_bar_title_widget.dart';
|
||||
import 'package:photos/ui/notification/toast.dart';
|
||||
|
||||
class LocalThumbnailConfigScreen extends StatefulWidget {
|
||||
const LocalThumbnailConfigScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LocalThumbnailConfigScreen> createState() =>
|
||||
_LocalThumbnailConfigScreenState();
|
||||
}
|
||||
|
||||
class _LocalThumbnailConfigScreenState
|
||||
extends State<LocalThumbnailConfigScreen> {
|
||||
static final Logger _logger = Logger("LocalThumbnailConfigScreen");
|
||||
|
||||
late TextEditingController _smallMaxConcurrentController;
|
||||
late TextEditingController _smallTimeoutController;
|
||||
late TextEditingController _smallMaxSizeController;
|
||||
late TextEditingController _largeMaxConcurrentController;
|
||||
late TextEditingController _largeTimeoutController;
|
||||
late TextEditingController _largeMaxSizeController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initControllers();
|
||||
}
|
||||
|
||||
void _initControllers() {
|
||||
_smallMaxConcurrentController = TextEditingController(
|
||||
text: localSettings.smallQueueMaxConcurrent.toString(),
|
||||
);
|
||||
_smallTimeoutController = TextEditingController(
|
||||
text: localSettings.smallQueueTimeoutSeconds.toString(),
|
||||
);
|
||||
_smallMaxSizeController = TextEditingController(
|
||||
text: localSettings.smallQueueMaxSize.toString(),
|
||||
);
|
||||
_largeMaxConcurrentController = TextEditingController(
|
||||
text: localSettings.largeQueueMaxConcurrent.toString(),
|
||||
);
|
||||
_largeTimeoutController = TextEditingController(
|
||||
text: localSettings.largeQueueTimeoutSeconds.toString(),
|
||||
);
|
||||
_largeMaxSizeController = TextEditingController(
|
||||
text: localSettings.largeQueueMaxSize.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_smallMaxConcurrentController.dispose();
|
||||
_smallTimeoutController.dispose();
|
||||
_smallMaxSizeController.dispose();
|
||||
_largeMaxConcurrentController.dispose();
|
||||
_largeTimeoutController.dispose();
|
||||
_largeMaxSizeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _saveSettings() async {
|
||||
try {
|
||||
// Validate and save small queue settings
|
||||
final smallMaxConcurrent =
|
||||
int.tryParse(_smallMaxConcurrentController.text);
|
||||
final smallTimeout = int.tryParse(_smallTimeoutController.text);
|
||||
final smallMaxSize = int.tryParse(_smallMaxSizeController.text);
|
||||
|
||||
// Validate and save large queue settings
|
||||
final largeMaxConcurrent =
|
||||
int.tryParse(_largeMaxConcurrentController.text);
|
||||
final largeTimeout = int.tryParse(_largeTimeoutController.text);
|
||||
final largeMaxSize = int.tryParse(_largeMaxSizeController.text);
|
||||
|
||||
if (smallMaxConcurrent == null ||
|
||||
smallTimeout == null ||
|
||||
smallMaxSize == null ||
|
||||
largeMaxConcurrent == null ||
|
||||
largeTimeout == null ||
|
||||
largeMaxSize == null) {
|
||||
showShortToast(context, "Please enter valid numbers");
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic validation - just ensure positive numbers
|
||||
if (smallMaxConcurrent < 1 ||
|
||||
largeMaxConcurrent < 1 ||
|
||||
smallTimeout < 1 ||
|
||||
largeTimeout < 1 ||
|
||||
smallMaxSize < 1 ||
|
||||
largeMaxSize < 1) {
|
||||
showShortToast(
|
||||
context,
|
||||
"All values must be positive numbers",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await localSettings.setSmallQueueMaxConcurrent(smallMaxConcurrent);
|
||||
await localSettings.setSmallQueueTimeout(smallTimeout);
|
||||
await localSettings.setSmallQueueMaxSize(smallMaxSize);
|
||||
await localSettings.setLargeQueueMaxConcurrent(largeMaxConcurrent);
|
||||
await localSettings.setLargeQueueTimeout(largeTimeout);
|
||||
await localSettings.setLargeQueueMaxSize(largeMaxSize);
|
||||
|
||||
_logger.info(
|
||||
"Local thumbnail queue settings updated:\n"
|
||||
"Small Queue - MaxConcurrent: $smallMaxConcurrent, Timeout: ${smallTimeout}s, MaxSize: $smallMaxSize\n"
|
||||
"Large Queue - MaxConcurrent: $largeMaxConcurrent, Timeout: ${largeTimeout}s, MaxSize: $largeMaxSize",
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
showShortToast(
|
||||
context,
|
||||
"Settings saved. Restart app to apply changes.",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
showShortToast(context, "Error saving settings");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resetToDefaults() async {
|
||||
await localSettings.resetThumbnailQueueSettings();
|
||||
setState(() {
|
||||
_smallMaxConcurrentController.text = "15";
|
||||
_smallTimeoutController.text = "60";
|
||||
_smallMaxSizeController.text = "200";
|
||||
_largeMaxConcurrentController.text = "5";
|
||||
_largeTimeoutController.text = "60";
|
||||
_largeMaxSizeController.text = "200";
|
||||
});
|
||||
|
||||
_logger.info(
|
||||
"Local thumbnail queue settings reset to defaults:\n"
|
||||
"Small Queue - MaxConcurrent: 15, Timeout: 60s, MaxSize: 200\n"
|
||||
"Large Queue - MaxConcurrent: 5, Timeout: 60s, MaxSize: 200",
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
showShortToast(
|
||||
context,
|
||||
"Reset to defaults. Restart app to apply changes.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildNumberField({
|
||||
required String label,
|
||||
required String hint,
|
||||
required TextEditingController controller,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: getEnteTextTheme(context).body.copyWith(
|
||||
color: getEnteColorScheme(context).textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
TextField(
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: getEnteTextTheme(context).body.copyWith(
|
||||
color: getEnteColorScheme(context).textFaint,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: getEnteColorScheme(context).fillFaint,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
style: getEnteTextTheme(context).body,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = getEnteColorScheme(context);
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
color: colorScheme.backdropBase,
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const TitleBarTitleWidget(
|
||||
title: "Local Thumbnail Queue Config",
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"Small Local Thumbnail Queue",
|
||||
style: getEnteTextTheme(context).largeBold,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
"Used when gallery grid has 4 or more columns",
|
||||
style: getEnteTextTheme(context).small.copyWith(
|
||||
color: colorScheme.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildNumberField(
|
||||
label: "Max Concurrent Tasks",
|
||||
hint: "Default: 15",
|
||||
controller: _smallMaxConcurrentController,
|
||||
),
|
||||
_buildNumberField(
|
||||
label: "Timeout (seconds)",
|
||||
hint: "Default: 60",
|
||||
controller: _smallTimeoutController,
|
||||
),
|
||||
_buildNumberField(
|
||||
label: "Max Queue Size",
|
||||
hint: "Default: 200",
|
||||
controller: _smallMaxSizeController,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
"Large Local Thumbnail Queue",
|
||||
style: getEnteTextTheme(context).largeBold,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
"Used when gallery grid has less than 4 columns",
|
||||
style: getEnteTextTheme(context).small.copyWith(
|
||||
color: colorScheme.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildNumberField(
|
||||
label: "Max Concurrent Tasks",
|
||||
hint: "Default: 5",
|
||||
controller: _largeMaxConcurrentController,
|
||||
),
|
||||
_buildNumberField(
|
||||
label: "Timeout (seconds)",
|
||||
hint: "Default: 60",
|
||||
controller: _largeTimeoutController,
|
||||
),
|
||||
_buildNumberField(
|
||||
label: "Max Queue Size",
|
||||
hint: "Default: 200",
|
||||
controller: _largeMaxSizeController,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _saveSettings,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary700,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
"Save Settings",
|
||||
style: getEnteTextTheme(context).bodyBold.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: TextButton(
|
||||
onPressed: _resetToDefaults,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: colorScheme.primary700,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
side: BorderSide(
|
||||
color: colorScheme.strokeMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
"Reset to Defaults",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.fillFaint,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 20,
|
||||
color: colorScheme.textMuted,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
"Changes require app restart to take effect",
|
||||
style: getEnteTextTheme(context).small.copyWith(
|
||||
color: colorScheme.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import "package:photos/services/machine_learning/face_ml/person/person_service.d
|
||||
import "package:photos/services/machine_learning/ml_indexing_isolate.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/machine_learning/similar_images_service.dart";
|
||||
import "package:photos/services/notification_service.dart";
|
||||
import "package:photos/services/search_service.dart";
|
||||
import "package:photos/src/rust/api/simple.dart";
|
||||
@@ -83,6 +84,25 @@ class _MLDebugSectionWidgetState extends State<MLDebugSectionWidget> {
|
||||
logger.info("Building ML Debug section options");
|
||||
return Column(
|
||||
children: [
|
||||
sectionOptionSpacing,
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: const CaptionedTextWidget(
|
||||
title: "Clear vectorDB index",
|
||||
),
|
||||
pressedColor: getEnteColorScheme(context).fillFaint,
|
||||
trailingIcon: Icons.chevron_right_outlined,
|
||||
trailingIconIsMuted: true,
|
||||
onTap: () async {
|
||||
try {
|
||||
await ClipVectorDB.instance.deleteIndexFile(undoMigration: true);
|
||||
await SimilarImagesService.instance.clearCache();
|
||||
showShortToast(context, 'Deleted vectorDB index');
|
||||
} catch (e, s) {
|
||||
logger.severe('vectorDB index delete failed ', e, s);
|
||||
await showGenericErrorDialog(context: context, error: e);
|
||||
}
|
||||
},
|
||||
),
|
||||
sectionOptionSpacing,
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: const CaptionedTextWidget(
|
||||
|
||||
@@ -100,7 +100,7 @@ class GeneralSectionWidget extends StatelessWidget {
|
||||
await routeToPage(
|
||||
context,
|
||||
LanguageSelectorPage(
|
||||
AppLocalizations.supportedLocales,
|
||||
appSupportedLocales,
|
||||
(locale) async {
|
||||
await setLocale(locale);
|
||||
EnteApp.setLocale(context, locale);
|
||||
|
||||
@@ -159,16 +159,11 @@ class _ItemsWidgetState extends State<ItemsWidget> {
|
||||
return 'Русский';
|
||||
case 'tr':
|
||||
return 'Türkçe';
|
||||
case 'fi':
|
||||
return 'Suomi';
|
||||
case 'zh':
|
||||
if (locale.countryCode == 'CN') {
|
||||
return '中文 (简体)';
|
||||
}
|
||||
return '中文';
|
||||
case 'zh-CN':
|
||||
return '中文';
|
||||
case 'ko':
|
||||
return '한국어';
|
||||
case 'ar':
|
||||
return 'العربية';
|
||||
case 'uk':
|
||||
return 'Українська';
|
||||
case 'vi':
|
||||
|
||||
@@ -114,7 +114,7 @@ class _AppLockState extends State<AppLock> with WidgetsBindingObserver {
|
||||
darkTheme: widget.darkTheme,
|
||||
locale: widget.locale,
|
||||
debugShowCheckedModeBanner: false,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
supportedLocales: appSupportedLocales,
|
||||
localeListResolutionCallback: localResolutionCallBack,
|
||||
localizationsDelegates: const [
|
||||
...AppLocalizations.localizationsDelegates,
|
||||
|
||||
@@ -39,7 +39,7 @@ class CircularIconButton extends StatelessWidget {
|
||||
? Theme.of(context)
|
||||
.colorScheme
|
||||
.imageEditorPrimaryColor
|
||||
.withOpacity(0.24)
|
||||
.withValues(alpha: 0.24)
|
||||
: Theme.of(context).colorScheme.editorBackgroundColor,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
|
||||
@@ -179,7 +179,7 @@ class _ImageEditorPageState extends State<ImageEditorPage> {
|
||||
final textTheme = getEnteTextTheme(context);
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvoked: (didPop) {
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (didPop) return;
|
||||
editorKey.currentState?.disablePopScope = true;
|
||||
_showExitConfirmationDialog(context);
|
||||
@@ -366,7 +366,7 @@ class _ImageEditorPageState extends State<ImageEditorPage> {
|
||||
margin: const EdgeInsets.only(bottom: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: isHovered
|
||||
? colorScheme.warning400.withOpacity(0.8)
|
||||
? colorScheme.warning400.withValues(alpha: 0.8)
|
||||
: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
@@ -378,7 +378,7 @@ class _ImageEditorPageState extends State<ImageEditorPage> {
|
||||
isHovered
|
||||
? Colors.white
|
||||
: colorScheme.warning400
|
||||
.withOpacity(0.8),
|
||||
.withValues(alpha: 0.8),
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -233,10 +233,10 @@ class _BackgroundPickerWidget extends StatelessWidget {
|
||||
'backgroundColor': Theme.of(context).colorScheme.editorBackgroundColor,
|
||||
'border': null,
|
||||
'textColor': Colors.black,
|
||||
'selectedInnerBackgroundColor': Colors.black.withOpacity(0.11),
|
||||
'selectedInnerBackgroundColor': Colors.black.withValues(alpha: 0.11),
|
||||
'innerBackgroundColor': isLightMode
|
||||
? Colors.black.withOpacity(0.11)
|
||||
: Colors.white.withOpacity(0.11),
|
||||
? Colors.black.withValues(alpha: 0.11)
|
||||
: Colors.white.withValues(alpha: 0.11),
|
||||
},
|
||||
LayerBackgroundMode.onlyColor: {
|
||||
'text': 'Aa',
|
||||
@@ -247,7 +247,7 @@ class _BackgroundPickerWidget extends StatelessWidget {
|
||||
isLightMode ? null : Border.all(color: Colors.white, width: 2),
|
||||
'textColor': Colors.black,
|
||||
'selectedInnerBackgroundColor': Colors.white,
|
||||
'innerBackgroundColor': Colors.white.withOpacity(0.6),
|
||||
'innerBackgroundColor': Colors.white.withValues(alpha: 0.6),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -320,11 +320,11 @@ class _CircularProgressWithValueState extends State<CircularProgressWithValue>
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: showValue || widget.isSelected
|
||||
? progressColor.withOpacity(0.2)
|
||||
? progressColor.withValues(alpha: 0.2)
|
||||
: Theme.of(context).colorScheme.editorBackgroundColor,
|
||||
border: Border.all(
|
||||
color: widget.isSelected
|
||||
? progressColor.withOpacity(0.4)
|
||||
? progressColor.withValues(alpha: 0.4)
|
||||
: Theme.of(context).colorScheme.editorBackgroundColor,
|
||||
width: 2,
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import "dart:async";
|
||||
|
||||
import "package:flutter/foundation.dart" show kDebugMode;
|
||||
import 'package:flutter/material.dart';
|
||||
import "package:flutter_spinkit/flutter_spinkit.dart" show SpinKitFadingCircle;
|
||||
import "package:flutter_svg/svg.dart";
|
||||
import "package:intl/intl.dart";
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -13,6 +14,7 @@ import "package:photos/models/selected_files.dart";
|
||||
import "package:photos/models/similar_files.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/machine_learning/similar_images_service.dart";
|
||||
import "package:photos/theme/colors.dart";
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
@@ -56,7 +58,8 @@ class SimilarImagesPage extends StatefulWidget {
|
||||
State<SimilarImagesPage> createState() => _SimilarImagesPageState();
|
||||
}
|
||||
|
||||
class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
class _SimilarImagesPageState extends State<SimilarImagesPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
static const crossAxisCount = 3;
|
||||
static const crossAxisSpacing = 12.0;
|
||||
static const double _closeThreshold = 0.02;
|
||||
@@ -76,6 +79,8 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
|
||||
late SelectedFiles _selectedFiles;
|
||||
late ValueNotifier<String> _deleteProgress;
|
||||
late ScrollController _scrollController;
|
||||
late AnimationController deleteAnimationController;
|
||||
|
||||
List<SimilarFiles> get _filteredGroups {
|
||||
final filteredGroups = <SimilarFiles>[];
|
||||
@@ -110,6 +115,11 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
super.initState();
|
||||
_selectedFiles = SelectedFiles();
|
||||
_deleteProgress = ValueNotifier("");
|
||||
_scrollController = ScrollController();
|
||||
deleteAnimationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
);
|
||||
|
||||
if (!widget.debugScreen) {
|
||||
_findSimilarImages();
|
||||
@@ -121,6 +131,8 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
_isDisposed = true;
|
||||
_selectedFiles.dispose();
|
||||
_deleteProgress.dispose();
|
||||
_scrollController.dispose();
|
||||
deleteAnimationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -152,52 +164,59 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
ValueListenableBuilder(
|
||||
valueListenable: _deleteProgress,
|
||||
builder: (context, value, child) {
|
||||
if (value.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final colorScheme = getEnteColorScheme(context);
|
||||
final textTheme = getEnteTextTheme(context);
|
||||
final fontFeatures = textTheme.small.fontFeatures ?? [];
|
||||
|
||||
return Container(
|
||||
color: colorScheme.backgroundBase.withValues(alpha: 0.8),
|
||||
child: Center(
|
||||
return AnimatedCrossFade(
|
||||
firstCurve: Curves.easeInOutExpo,
|
||||
secondCurve: Curves.easeInOutExpo,
|
||||
sizeCurve: Curves.easeInOutExpo,
|
||||
crossFadeState: value.isEmpty
|
||||
? CrossFadeState.showFirst
|
||||
: CrossFadeState.showSecond,
|
||||
duration: const Duration(milliseconds: 400),
|
||||
secondChild: Align(
|
||||
alignment: Alignment.center,
|
||||
child: Container(
|
||||
height: 42,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 12)
|
||||
.copyWith(left: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.backgroundElevated,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.strokeFaint,
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
color: Colors.black.withValues(alpha: 0.72),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation(colorScheme.primary500),
|
||||
child: SpinKitFadingCircle(
|
||||
size: 18,
|
||||
color: colorScheme.warning500,
|
||||
controller: deleteAnimationController,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
AppLocalizations.of(context)
|
||||
.deletingProgress(progress: value),
|
||||
style: textTheme.body,
|
||||
AppLocalizations.of(context).deletingDash,
|
||||
style: textTheme.small.copyWith(color: Colors.white),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: textTheme.small.copyWith(
|
||||
color: Colors.white,
|
||||
fontFeatures: [
|
||||
const FontFeature.tabularFigures(),
|
||||
...fontFeatures,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
firstChild: const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -326,28 +345,7 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
}
|
||||
|
||||
Widget _getLoadingView() {
|
||||
final textTheme = getEnteTextTheme(context);
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 160,
|
||||
child: RiveAnimation.asset(
|
||||
'assets/ducky_analyze_files.riv',
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
AppLocalizations.of(context).analyzingPhotosLocally,
|
||||
style: textTheme.bodyMuted,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return const _LoadingScreen();
|
||||
}
|
||||
|
||||
Widget _getResultsView() {
|
||||
@@ -405,12 +403,18 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
controller: _scrollController,
|
||||
cacheExtent: 400,
|
||||
itemCount: _filteredGroups.length,
|
||||
itemBuilder: (context, index) {
|
||||
final similarFiles = _filteredGroups[index];
|
||||
return RepaintBoundary(
|
||||
child: _buildSimilarFilesGroup(similarFiles),
|
||||
return Column(
|
||||
children: [
|
||||
if (index == 0) const SizedBox(height: 16),
|
||||
RepaintBoundary(
|
||||
child: _buildSimilarFilesGroup(similarFiles),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -486,7 +490,9 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
final newSelection = <EnteFile>{};
|
||||
for (final group in _filteredGroups) {
|
||||
for (int i = 1; i < group.files.length; i++) {
|
||||
newSelection.add(group.files[i]);
|
||||
final file = group.files[i];
|
||||
if (FavoritesService.instance.isFavoriteCache(file)) continue;
|
||||
newSelection.add(file);
|
||||
}
|
||||
}
|
||||
_selectedFiles.clearAll();
|
||||
@@ -498,21 +504,24 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
return ListenableBuilder(
|
||||
listenable: _selectedFiles,
|
||||
builder: (context, _) {
|
||||
final selectedFiles = _selectedFiles.files;
|
||||
final selectedCount = selectedFiles.length;
|
||||
final hasSelectedFiles = selectedCount > 0;
|
||||
|
||||
final eligibleFilteredFiles = <EnteFile>{};
|
||||
int autoSelectCount = 0;
|
||||
for (final group in _filteredGroups) {
|
||||
for (int i = 1; i < group.files.length; i++) {
|
||||
eligibleFilteredFiles.add(group.files[i]);
|
||||
for (int i = 0; i < group.files.length; i++) {
|
||||
final file = group.files[i];
|
||||
eligibleFilteredFiles.add(file);
|
||||
if (i != 0 && !FavoritesService.instance.isFavoriteCache(file)) {
|
||||
autoSelectCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
final selectedFiles = _selectedFiles.files;
|
||||
|
||||
final selectedFilteredFiles =
|
||||
selectedFiles.intersection(eligibleFilteredFiles);
|
||||
final allFilteredSelected = eligibleFilteredFiles.isNotEmpty &&
|
||||
selectedFilteredFiles.length == eligibleFilteredFiles.length;
|
||||
selectedFilteredFiles.length >= autoSelectCount;
|
||||
final hasSelectedFiles = selectedFilteredFiles.isNotEmpty;
|
||||
|
||||
int totalSize = 0;
|
||||
for (final file in selectedFilteredFiles) {
|
||||
@@ -571,6 +580,7 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
selectedFilteredFiles,
|
||||
showDialog: true,
|
||||
showUIFeedback: true,
|
||||
scrollToTop: true,
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -590,7 +600,7 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
shouldSurfaceExecutionStates: false,
|
||||
shouldShowSuccessConfirmation: false,
|
||||
onTap: () async {
|
||||
_toggleSelectAll();
|
||||
_toggleSelectAll(allFilteredSelected);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -602,22 +612,20 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _toggleSelectAll() {
|
||||
final eligibleFiles = <EnteFile>{};
|
||||
void _toggleSelectAll(bool allSelected) {
|
||||
final autoSelectFiles = <EnteFile>{};
|
||||
for (final group in _filteredGroups) {
|
||||
for (int i = 1; i < group.files.length; i++) {
|
||||
eligibleFiles.add(group.files[i]);
|
||||
final file = group.files[i];
|
||||
if (FavoritesService.instance.isFavoriteCache(file)) continue;
|
||||
autoSelectFiles.add(file);
|
||||
}
|
||||
}
|
||||
|
||||
final currentSelected = _selectedFiles.files.intersection(eligibleFiles);
|
||||
final allSelected = eligibleFiles.isNotEmpty &&
|
||||
currentSelected.length == eligibleFiles.length;
|
||||
|
||||
if (allSelected) {
|
||||
_selectedFiles.unSelectAll(eligibleFiles);
|
||||
_selectedFiles.clearAll();
|
||||
} else {
|
||||
_selectedFiles.selectAll(eligibleFiles);
|
||||
_selectedFiles.selectAll(autoSelectFiles);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -646,7 +654,9 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
for (final group in _similarFilesList) {
|
||||
if (group.files.length > 1) {
|
||||
for (int i = 1; i < group.files.length; i++) {
|
||||
_selectedFiles.toggleSelection(group.files[i]);
|
||||
final file = group.files[i];
|
||||
if (FavoritesService.instance.isFavoriteCache(file)) continue;
|
||||
_selectedFiles.toggleSelection(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -662,10 +672,7 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
await showGenericErrorDialog(context: context, error: e);
|
||||
}
|
||||
if (_isDisposed) return;
|
||||
setState(() {
|
||||
_pageState = SimilarImagesPageState.setup;
|
||||
});
|
||||
return;
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -931,6 +938,7 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
Set<EnteFile> filesToDelete, {
|
||||
bool showDialog = true,
|
||||
bool showUIFeedback = true,
|
||||
bool scrollToTop = false,
|
||||
}) async {
|
||||
if (filesToDelete.isEmpty) return;
|
||||
if (showDialog) {
|
||||
@@ -946,6 +954,7 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
filesToDelete,
|
||||
true,
|
||||
showUIFeedback: showUIFeedback,
|
||||
scrollToTop: scrollToTop,
|
||||
);
|
||||
} catch (e, s) {
|
||||
_logger.severe("Failed to delete files", e, s);
|
||||
@@ -960,6 +969,7 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
filesToDelete,
|
||||
true,
|
||||
showUIFeedback: showUIFeedback,
|
||||
scrollToTop: scrollToTop,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -968,6 +978,7 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
Set<EnteFile> filesToDelete,
|
||||
bool createSymlink, {
|
||||
bool showUIFeedback = true,
|
||||
bool scrollToTop = false,
|
||||
}) async {
|
||||
if (filesToDelete.isEmpty) {
|
||||
return;
|
||||
@@ -1050,6 +1061,15 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
setState(() {});
|
||||
await deleteFilesFromRemoteOnly(context, allDeleteFiles.toList());
|
||||
|
||||
// Scroll to top if requested
|
||||
if (scrollToTop && mounted) {
|
||||
await _scrollController.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
|
||||
// Show congratulations popup
|
||||
if (allDeleteFiles.length > 100 && mounted && showUIFeedback) {
|
||||
final int totalSize = allDeleteFiles.fold<int>(
|
||||
@@ -1183,3 +1203,83 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoadingScreen extends StatefulWidget {
|
||||
const _LoadingScreen();
|
||||
|
||||
@override
|
||||
State<_LoadingScreen> createState() => _LoadingScreenState();
|
||||
}
|
||||
|
||||
class _LoadingScreenState extends State<_LoadingScreen> {
|
||||
Timer? _timer;
|
||||
int _currentTextIndex = 0;
|
||||
|
||||
late List<String> _loadingTexts;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startTextCycling();
|
||||
}
|
||||
|
||||
void _startTextCycling() {
|
||||
_timer = Timer.periodic(const Duration(seconds: 7), (timer) {
|
||||
if (_currentTextIndex < _loadingTexts.length - 1) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentTextIndex++;
|
||||
});
|
||||
}
|
||||
// Stop the timer when we reach the last text
|
||||
if (_currentTextIndex >= _loadingTexts.length - 1) {
|
||||
timer.cancel();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = getEnteTextTheme(context);
|
||||
|
||||
_loadingTexts = [
|
||||
AppLocalizations.of(context).analyzingPhotosLocally,
|
||||
AppLocalizations.of(context).lookingForVisualSimilarities,
|
||||
AppLocalizations.of(context).comparingImageDetails,
|
||||
AppLocalizations.of(context).findingSimilarImages,
|
||||
AppLocalizations.of(context).almostDone,
|
||||
];
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 160,
|
||||
child: RiveAnimation.asset(
|
||||
'assets/ducky_analyze_files.riv',
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
child: Text(
|
||||
_loadingTexts[_currentTextIndex],
|
||||
key: ValueKey<int>(_currentTextIndex),
|
||||
style: textTheme.bodyMuted,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ class _SmartAlbumsStatusWidgetState extends State<SmartAlbumsStatusWidget>
|
||||
.copyWith(left: 14),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Colors.black.withOpacity(0.65),
|
||||
color: Colors.black.withValues(alpha: 0.65),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -499,7 +499,8 @@ class FileAppBarState extends State<FileAppBar> {
|
||||
widget.file.isUploaded &&
|
||||
widget.file.fileSize != null &&
|
||||
(widget.file.pubMagicMetadata?.sv ?? 0) != 1 &&
|
||||
widget.file.ownerID == userId;
|
||||
widget.file.ownerID == userId &&
|
||||
VideoPreviewService.instance.isVideoStreamingEnabled;
|
||||
}
|
||||
|
||||
Future<void> _handleVideoStream(String streamType) async {
|
||||
|
||||
@@ -8,22 +8,24 @@ import 'package:photos/core/cache/thumbnail_in_memory_cache.dart';
|
||||
import 'package:photos/core/constants.dart';
|
||||
import 'package:photos/core/errors.dart';
|
||||
import 'package:photos/core/event_bus.dart';
|
||||
import 'package:photos/core/exceptions.dart';
|
||||
import 'package:photos/db/files_db.dart';
|
||||
import 'package:photos/db/trash_db.dart';
|
||||
import 'package:photos/events/files_updated_event.dart';
|
||||
import "package:photos/events/local_photos_updated_event.dart";
|
||||
import "package:photos/models/api/collection/user.dart";
|
||||
import "package:photos/models/file/extensions/file_props.dart";
|
||||
import 'package:photos/events/local_photos_updated_event.dart';
|
||||
import 'package:photos/models/api/collection/user.dart';
|
||||
import 'package:photos/models/file/extensions/file_props.dart';
|
||||
import 'package:photos/models/file/file.dart';
|
||||
import 'package:photos/models/file/file_type.dart';
|
||||
import 'package:photos/models/file/trash_file.dart';
|
||||
import 'package:photos/service_locator.dart';
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import 'package:photos/services/favorites_service.dart';
|
||||
import 'package:photos/ui/viewer/file/file_icons_widget.dart';
|
||||
import "package:photos/ui/viewer/gallery/component/group/type.dart";
|
||||
import "package:photos/ui/viewer/gallery/state/gallery_context_state.dart";
|
||||
import 'package:photos/ui/viewer/gallery/component/group/type.dart';
|
||||
import 'package:photos/ui/viewer/gallery/state/gallery_context_state.dart';
|
||||
import 'package:photos/utils/file_util.dart';
|
||||
import "package:photos/utils/standalone/task_queue.dart";
|
||||
import 'package:photos/utils/standalone/task_queue.dart';
|
||||
import 'package:photos/utils/thumbnail_util.dart';
|
||||
|
||||
class ThumbnailWidget extends StatefulWidget {
|
||||
@@ -97,13 +99,20 @@ class _ThumbnailWidgetState extends State<ThumbnailWidget> {
|
||||
if (!mounted && _localThumbnailQueueTaskId != null) {
|
||||
if (widget.thumbnailSize == thumbnailLargeSize) {
|
||||
largeLocalThumbnailQueue.removeTask(_localThumbnailQueueTaskId!);
|
||||
_logger.info(
|
||||
"Cancelled large thumbnail task: $_localThumbnailQueueTaskId",
|
||||
);
|
||||
} else if (widget.thumbnailSize == thumbnailSmallSize) {
|
||||
smallLocalThumbnailQueue.removeTask(_localThumbnailQueueTaskId!);
|
||||
_logger.info(
|
||||
"Cancelled small thumbnail task: $_localThumbnailQueueTaskId",
|
||||
);
|
||||
}
|
||||
}
|
||||
// Cancel request only if the widget has been unmounted
|
||||
if (!mounted && widget.file.isRemoteFile && !_hasLoadedThumbnail) {
|
||||
removePendingGetThumbnailRequestIfAny(widget.file);
|
||||
_logger.info("Cancelled thumbnail request for " + widget.file.tag);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -116,17 +125,42 @@ class _ThumbnailWidgetState extends State<ThumbnailWidget> {
|
||||
}
|
||||
}
|
||||
|
||||
static final smallLocalThumbnailQueue = TaskQueue<String>(
|
||||
maxConcurrentTasks: 15,
|
||||
taskTimeout: const Duration(minutes: 1),
|
||||
maxQueueSize: 200,
|
||||
);
|
||||
static final TaskQueue<String> smallLocalThumbnailQueue = _initSmallQueue();
|
||||
static final TaskQueue<String> largeLocalThumbnailQueue = _initLargeQueue();
|
||||
|
||||
static final largeLocalThumbnailQueue = TaskQueue<String>(
|
||||
maxConcurrentTasks: 5,
|
||||
taskTimeout: const Duration(minutes: 1),
|
||||
maxQueueSize: 200,
|
||||
);
|
||||
static TaskQueue<String> _initSmallQueue() {
|
||||
final maxConcurrent = localSettings.smallQueueMaxConcurrent;
|
||||
final timeoutSeconds = localSettings.smallQueueTimeoutSeconds;
|
||||
final maxSize = localSettings.smallQueueMaxSize;
|
||||
|
||||
_logger.info(
|
||||
"Initializing Small Local Thumbnail Queue - "
|
||||
"MaxConcurrent: $maxConcurrent, Timeout: ${timeoutSeconds}s, MaxSize: $maxSize",
|
||||
);
|
||||
|
||||
return TaskQueue<String>(
|
||||
maxConcurrentTasks: maxConcurrent,
|
||||
taskTimeout: Duration(seconds: timeoutSeconds),
|
||||
maxQueueSize: maxSize,
|
||||
);
|
||||
}
|
||||
|
||||
static TaskQueue<String> _initLargeQueue() {
|
||||
final maxConcurrent = localSettings.largeQueueMaxConcurrent;
|
||||
final timeoutSeconds = localSettings.largeQueueTimeoutSeconds;
|
||||
final maxSize = localSettings.largeQueueMaxSize;
|
||||
|
||||
_logger.info(
|
||||
"Initializing Large Local Thumbnail Queue - "
|
||||
"MaxConcurrent: $maxConcurrent, Timeout: ${timeoutSeconds}s, MaxSize: $maxSize",
|
||||
);
|
||||
|
||||
return TaskQueue<String>(
|
||||
maxConcurrentTasks: maxConcurrent,
|
||||
taskTimeout: Duration(seconds: timeoutSeconds),
|
||||
maxQueueSize: maxSize,
|
||||
);
|
||||
}
|
||||
|
||||
///Assigned dimension will be the size of a grid item. The size will be
|
||||
///assigned to the side which is smaller in dimension.
|
||||
@@ -229,6 +263,32 @@ class _ThumbnailWidgetState extends State<ThumbnailWidget> {
|
||||
if (widget.shouldShowPinIcon) {
|
||||
viewChildren.add(const PinOverlayIcon());
|
||||
}
|
||||
if (localSettings.showLocalIDOverThumbnails &&
|
||||
widget.file.localID != null) {
|
||||
viewChildren.add(
|
||||
Positioned(
|
||||
bottom: 4,
|
||||
left: 4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color.fromRGBO(0, 0, 0, 0.8),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: FittedBox(
|
||||
child: Text(
|
||||
"${widget.file.localID}",
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
@@ -307,8 +367,18 @@ class _ThumbnailWidgetState extends State<ThumbnailWidget> {
|
||||
thumbnailSmallSize,
|
||||
);
|
||||
}).catchError((e) {
|
||||
_logger.warning("Could not load thumbnail from disk: ", e);
|
||||
_errorLoadingLocalThumbnail = true;
|
||||
if (e is WidgetUnmountedException) {
|
||||
// Widget was unmounted - this is expected behavior
|
||||
_logger.fine(
|
||||
"Thumbnail loading cancelled: widget unmounted for localID: ${widget.file.localID}",
|
||||
);
|
||||
} else {
|
||||
_logger.warning(
|
||||
"Could not load thumbnail from disk for localID: ${widget.file.localID}",
|
||||
e,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -326,7 +396,9 @@ class _ThumbnailWidgetState extends State<ThumbnailWidget> {
|
||||
}
|
||||
//Do not retry if the widget is not mounted
|
||||
if (!mounted) {
|
||||
return null;
|
||||
throw WidgetUnmountedException(
|
||||
"Thumbnail loading cancelled: widget unmounted",
|
||||
);
|
||||
}
|
||||
|
||||
retryAttempts++;
|
||||
@@ -335,7 +407,7 @@ class _ThumbnailWidgetState extends State<ThumbnailWidget> {
|
||||
);
|
||||
if (retryAttempts <= _maxLocalThumbnailRetries) {
|
||||
_logger.warning(
|
||||
"Error getting local thumbnail for ${widget.file.displayName}, retrying (attempt $retryAttempts) in ${backoff.inMilliseconds} ms",
|
||||
"Error getting local thumbnail for ${widget.file.displayName} (localID: ${widget.file.localID}) due to ${e.runtimeType}, retrying (attempt $retryAttempts) in ${backoff.inMilliseconds} ms",
|
||||
e,
|
||||
);
|
||||
await Future.delayed(backoff); // Exponential backoff
|
||||
@@ -366,11 +438,16 @@ class _ThumbnailWidgetState extends State<ThumbnailWidget> {
|
||||
}
|
||||
|
||||
await relevantTaskQueue.addTask(_localThumbnailQueueTaskId!, () async {
|
||||
final thumbnailBytes = await getThumbnailFromLocal(
|
||||
widget.file,
|
||||
size: widget.thumbnailSize,
|
||||
);
|
||||
completer.complete(thumbnailBytes);
|
||||
late final Uint8List? thumbnailBytes;
|
||||
try {
|
||||
thumbnailBytes = await getThumbnailFromLocal(
|
||||
widget.file,
|
||||
size: widget.thumbnailSize,
|
||||
);
|
||||
completer.complete(thumbnailBytes);
|
||||
} catch (e) {
|
||||
completer.completeError(e);
|
||||
}
|
||||
});
|
||||
|
||||
return completer.future;
|
||||
|
||||
@@ -32,27 +32,34 @@ class VideoStreamChangeWidget extends StatefulWidget {
|
||||
|
||||
class _VideoStreamChangeWidgetState extends State<VideoStreamChangeWidget> {
|
||||
StreamSubscription<VideoPreviewStateChangedEvent>? _subscription;
|
||||
bool isCurrentlyProcessing = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initialize processing state safely in initState
|
||||
isCurrentlyProcessing = VideoPreviewService.instance
|
||||
.isCurrentlyProcessing(widget.file.uploadedFileID);
|
||||
|
||||
_subscription =
|
||||
Bus.instance.on<VideoPreviewStateChangedEvent>().listen((event) {
|
||||
final fileId = event.fileId;
|
||||
if (widget.file.uploadedFileID != fileId) {
|
||||
return; // Not for this file
|
||||
}
|
||||
|
||||
final status = event.status;
|
||||
|
||||
// Handle different states
|
||||
switch (status) {
|
||||
case PreviewItemStatus.inQueue:
|
||||
case PreviewItemStatus.uploaded:
|
||||
case PreviewItemStatus.failed:
|
||||
setState(() {});
|
||||
break;
|
||||
default:
|
||||
// Handle different states - will be false for different files or non-processing states
|
||||
final newProcessingState = widget.file.uploadedFileID == fileId && switch (status) {
|
||||
PreviewItemStatus.inQueue ||
|
||||
PreviewItemStatus.retry ||
|
||||
PreviewItemStatus.compressing ||
|
||||
PreviewItemStatus.uploading =>
|
||||
true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
// Only update state if value changed
|
||||
if (isCurrentlyProcessing != newProcessingState) {
|
||||
isCurrentlyProcessing = newProcessingState;
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -63,14 +70,28 @@ class _VideoStreamChangeWidgetState extends State<VideoStreamChangeWidget> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _getStatusText(BuildContext context, PreviewItemStatus? status) {
|
||||
switch (status) {
|
||||
case PreviewItemStatus.inQueue:
|
||||
case PreviewItemStatus.retry:
|
||||
return AppLocalizations.of(context).queued;
|
||||
case PreviewItemStatus.compressing:
|
||||
case PreviewItemStatus.uploading:
|
||||
default:
|
||||
return AppLocalizations.of(context).creatingStream;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool isPreviewAvailable = widget.file.uploadedFileID != null &&
|
||||
(fileDataService.previewIds.containsKey(widget.file.uploadedFileID));
|
||||
|
||||
// Check if this file is currently being processed for streaming
|
||||
final bool isCurrentlyProcessing = VideoPreviewService.instance
|
||||
.isCurrentlyProcessing(widget.file.uploadedFileID);
|
||||
// Get the current processing status for more specific messaging
|
||||
final processingStatus = widget.file.uploadedFileID != null
|
||||
? VideoPreviewService.instance
|
||||
.getProcessingStatus(widget.file.uploadedFileID!)
|
||||
: null;
|
||||
|
||||
final colorScheme = getEnteColorScheme(context);
|
||||
|
||||
@@ -125,7 +146,7 @@ class _VideoStreamChangeWidgetState extends State<VideoStreamChangeWidget> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
AppLocalizations.of(context).creatingStream,
|
||||
_getStatusText(context, processingStatus),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
@@ -604,7 +604,6 @@ class GalleryState extends State<Gallery> {
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: const ExponentialBouncingScrollPhysics(),
|
||||
controller: _scrollController,
|
||||
cacheExtent: galleryCacheExtent,
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: SizeChangedLayoutNotifier(
|
||||
@@ -642,25 +641,6 @@ class GalleryState extends State<Gallery> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double get galleryCacheExtent {
|
||||
final int photoGridSize = localSettings.getPhotoGridSize();
|
||||
switch (photoGridSize) {
|
||||
case 2:
|
||||
case 3:
|
||||
return 1000;
|
||||
case 4:
|
||||
return 850;
|
||||
case 5:
|
||||
return 600;
|
||||
case 6:
|
||||
return 300;
|
||||
default:
|
||||
throw StateError(
|
||||
'Invalid photo grid size configuration: $photoGridSize',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PinnedGroupHeader extends StatefulWidget {
|
||||
|
||||
@@ -279,7 +279,7 @@ class ScrollBarDivider extends StatelessWidget {
|
||||
// is affected.
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 3,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
|
||||
@@ -53,11 +53,11 @@ const Duration _kScrollbarTimeToFade = Duration(milliseconds: 600);
|
||||
/// {@end-tool}
|
||||
///
|
||||
/// A scrollbar track can be added using [trackVisibility]. This can also be
|
||||
/// drawn when triggered by a hover event, or based on any [MaterialState] by
|
||||
/// drawn when triggered by a hover event, or based on any [WidgetState] by
|
||||
/// using [ScrollbarThemeData.trackVisibility].
|
||||
///
|
||||
/// The [thickness] of the track and scrollbar thumb can be changed dynamically
|
||||
/// in response to [MaterialState]s using [ScrollbarThemeData.thickness].
|
||||
/// in response to [WidgetState]s using [ScrollbarThemeData.thickness].
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
@@ -262,17 +262,17 @@ class _MaterialScrollbarState extends RawScrollbarState<_MaterialScrollbar> {
|
||||
late Color idleColor;
|
||||
switch (brightness) {
|
||||
case Brightness.light:
|
||||
dragColor = onSurface.withOpacity(0.6);
|
||||
hoverColor = onSurface.withOpacity(0.5);
|
||||
dragColor = onSurface.withValues(alpha: 0.6);
|
||||
hoverColor = onSurface.withValues(alpha: 0.5);
|
||||
idleColor = _useAndroidScrollbar
|
||||
? Theme.of(context).highlightColor.withOpacity(1.0)
|
||||
: onSurface.withOpacity(0.1);
|
||||
? Theme.of(context).highlightColor.withValues(alpha: 1.0)
|
||||
: onSurface.withValues(alpha: 0.1);
|
||||
case Brightness.dark:
|
||||
dragColor = onSurface.withOpacity(0.75);
|
||||
hoverColor = onSurface.withOpacity(0.65);
|
||||
dragColor = onSurface.withValues(alpha: 0.75);
|
||||
hoverColor = onSurface.withValues(alpha: 0.65);
|
||||
idleColor = _useAndroidScrollbar
|
||||
? Theme.of(context).highlightColor.withOpacity(1.0)
|
||||
: onSurface.withOpacity(0.3);
|
||||
? Theme.of(context).highlightColor.withValues(alpha: 1.0)
|
||||
: onSurface.withValues(alpha: 0.3);
|
||||
}
|
||||
|
||||
return WidgetStateProperty.resolveWith((Set<WidgetState> states) {
|
||||
@@ -304,8 +304,8 @@ class _MaterialScrollbarState extends RawScrollbarState<_MaterialScrollbar> {
|
||||
if (showScrollbar && _trackVisibility.resolve(states)) {
|
||||
return _scrollbarTheme.trackColor?.resolve(states) ??
|
||||
switch (brightness) {
|
||||
Brightness.light => onSurface.withOpacity(0.03),
|
||||
Brightness.dark => onSurface.withOpacity(0.05),
|
||||
Brightness.light => onSurface.withValues(alpha: 0.03),
|
||||
Brightness.dark => onSurface.withValues(alpha: 0.05),
|
||||
};
|
||||
}
|
||||
return const Color(0x00000000);
|
||||
@@ -322,8 +322,8 @@ class _MaterialScrollbarState extends RawScrollbarState<_MaterialScrollbar> {
|
||||
if (showScrollbar && _trackVisibility.resolve(states)) {
|
||||
return _scrollbarTheme.trackBorderColor?.resolve(states) ??
|
||||
switch (brightness) {
|
||||
Brightness.light => onSurface.withOpacity(0.1),
|
||||
Brightness.dark => onSurface.withOpacity(0.25),
|
||||
Brightness.light => onSurface.withValues(alpha: 0.1),
|
||||
Brightness.dark => onSurface.withValues(alpha: 0.25),
|
||||
};
|
||||
}
|
||||
return const Color(0x00000000);
|
||||
|
||||
@@ -41,6 +41,15 @@ class LocalSettings {
|
||||
"hide_shared_items_from_home_gallery";
|
||||
static const kCollectionViewType = "collection_view_type";
|
||||
static const kCollectionSortDirection = "collection_sort_direction";
|
||||
static const kShowLocalIDOverThumbnails = "show_local_id_over_thumbnails";
|
||||
|
||||
// Thumbnail queue configuration keys
|
||||
static const kSmallQueueMaxConcurrent = "small_queue_max_concurrent";
|
||||
static const kSmallQueueTimeout = "small_queue_timeout_seconds";
|
||||
static const kSmallQueueMaxSize = "small_queue_max_size";
|
||||
static const kLargeQueueMaxConcurrent = "large_queue_max_concurrent";
|
||||
static const kLargeQueueTimeout = "large_queue_timeout_seconds";
|
||||
static const kLargeQueueMaxSize = "large_queue_max_size";
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
@@ -217,4 +226,59 @@ class LocalSettings {
|
||||
|
||||
bool get hideSharedItemsFromHomeGallery =>
|
||||
_prefs.getBool(_hideSharedItemsFromHomeGalleryTag) ?? false;
|
||||
|
||||
bool get showLocalIDOverThumbnails =>
|
||||
_prefs.getBool(kShowLocalIDOverThumbnails) ?? false;
|
||||
|
||||
Future<void> setShowLocalIDOverThumbnails(bool value) async {
|
||||
await _prefs.setBool(kShowLocalIDOverThumbnails, value);
|
||||
}
|
||||
|
||||
// Thumbnail queue configuration - Small queue
|
||||
int get smallQueueMaxConcurrent => _prefs.getInt(kSmallQueueMaxConcurrent) ?? 15;
|
||||
|
||||
int get smallQueueTimeoutSeconds => _prefs.getInt(kSmallQueueTimeout) ?? 60;
|
||||
|
||||
int get smallQueueMaxSize => _prefs.getInt(kSmallQueueMaxSize) ?? 200;
|
||||
|
||||
Future<void> setSmallQueueMaxConcurrent(int value) async {
|
||||
await _prefs.setInt(kSmallQueueMaxConcurrent, value);
|
||||
}
|
||||
|
||||
Future<void> setSmallQueueTimeout(int seconds) async {
|
||||
await _prefs.setInt(kSmallQueueTimeout, seconds);
|
||||
}
|
||||
|
||||
Future<void> setSmallQueueMaxSize(int value) async {
|
||||
await _prefs.setInt(kSmallQueueMaxSize, value);
|
||||
}
|
||||
|
||||
// Thumbnail queue configuration - Large queue
|
||||
int get largeQueueMaxConcurrent => _prefs.getInt(kLargeQueueMaxConcurrent) ?? 5;
|
||||
|
||||
int get largeQueueTimeoutSeconds => _prefs.getInt(kLargeQueueTimeout) ?? 60;
|
||||
|
||||
int get largeQueueMaxSize => _prefs.getInt(kLargeQueueMaxSize) ?? 200;
|
||||
|
||||
Future<void> setLargeQueueMaxConcurrent(int value) async {
|
||||
await _prefs.setInt(kLargeQueueMaxConcurrent, value);
|
||||
}
|
||||
|
||||
Future<void> setLargeQueueTimeout(int seconds) async {
|
||||
await _prefs.setInt(kLargeQueueTimeout, seconds);
|
||||
}
|
||||
|
||||
Future<void> setLargeQueueMaxSize(int value) async {
|
||||
await _prefs.setInt(kLargeQueueMaxSize, value);
|
||||
}
|
||||
|
||||
// Reset thumbnail queue settings to defaults
|
||||
Future<void> resetThumbnailQueueSettings() async {
|
||||
await _prefs.remove(kSmallQueueMaxConcurrent);
|
||||
await _prefs.remove(kSmallQueueTimeout);
|
||||
await _prefs.remove(kSmallQueueMaxSize);
|
||||
await _prefs.remove(kLargeQueueMaxConcurrent);
|
||||
await _prefs.remove(kLargeQueueTimeout);
|
||||
await _prefs.remove(kLargeQueueMaxSize);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ description: ente photos application
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
|
||||
version: 1.2.1+1205
|
||||
version: 1.2.4+1205
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
|
||||
@@ -32,8 +32,11 @@ impl VectorDB {
|
||||
|
||||
if file_exists {
|
||||
println!("Loading index from disk.");
|
||||
// Use view to not load the index into memory. https://docs.rs/usearch/latest/usearch/struct.Index.html#method.view
|
||||
db.index.view(file_path).expect("Failed to load index");
|
||||
// Must use load() instead of view() because:
|
||||
// - view() creates a read-only memory-mapped view (immutable)
|
||||
// - load() loads the index into RAM for read/write operations (mutable)
|
||||
// Using view() causes "Can't add to an immutable index" error
|
||||
db.index.load(file_path).expect("Failed to load index");
|
||||
} else {
|
||||
println!("Creating new index.");
|
||||
db.save_index();
|
||||
@@ -46,9 +49,37 @@ impl VectorDB {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("Failed to create directory");
|
||||
}
|
||||
self.index
|
||||
.save(self.path.to_str().expect("Invalid path"))
|
||||
.expect("Failed to save index");
|
||||
|
||||
// Use atomic write: save to temp file first, then rename
|
||||
let temp_path = self.path.with_extension("tmp");
|
||||
let temp_path_str = temp_path.to_str().expect("Invalid temp path");
|
||||
|
||||
// Save to temporary file
|
||||
match self.index.save(temp_path_str) {
|
||||
Ok(_) => {
|
||||
// Atomic rename - guaranteed atomic on iOS/Android
|
||||
// This will atomically replace the existing file
|
||||
// The rename ensures we never have a partially written file,
|
||||
// even if the app is suspended or crashes
|
||||
match std::fs::rename(&temp_path, &self.path) {
|
||||
Ok(_) => {
|
||||
println!("Successfully saved index atomically");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Failed to rename temp index file: {:?}", e);
|
||||
// Try to clean up temp file
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
panic!("Failed to atomically save index: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Failed to save index to temp file: {:?}", e);
|
||||
// Try to clean up temp file if it exists
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
panic!("Failed to save index: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_capacity(&self, margin: usize) {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
- Ashil: Changes meant for figuring out thumbnail not loading issue (this change has no scope for regressions or bugs)
|
||||
- Ashil: Add new section (show local ID & config local thumb queue) in debug section in settings to help in debugging thumbnail not loading issue.
|
||||
- Ashil: Revert diskLoadDeferDuration to 80ms (Fixes local thumbnails taking ~1 sec to load on scrolling gallery)
|
||||
- Ashil: Revert diskLoadDeferDuration to 500ms (Was 80ms before but fixes local thumbnail taking very long to load or never loading)
|
||||
- Ashil: Revert increase in cache extent for gallery - to check if thumbnail not loading regression resolves
|
||||
- Similar images design changes. Also changed the vectorDB index file name, so internal users will have another migration (long loading time).
|
||||
- Ashil: New ducky icon in icon switcher
|
||||
- Prateek: Enable immediate manual video stream processing by bypassing user interaction timer
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
- Video streaming improvements
|
||||
- Added support for custom domain links
|
||||
- Image editor fixes:
|
||||
- Fixed bottom navigation bar color in light theme
|
||||
- Resolved initial color issue in paint editor
|
||||
- Added tap-to-reset with haptics for tune adjustments (brightness/exposure)
|
||||
- Similar images detection and deletion
|
||||
- Video streaming enhancements
|
||||
- Performance improvements
|
||||
@@ -30,7 +30,8 @@ Future<bool> requestAuthentication(
|
||||
isAuthenticatingForInAppChange: isAuthenticatingForInAppChange,
|
||||
);
|
||||
}
|
||||
if (Platform.isMacOS || Platform.isLinux) {
|
||||
if (Platform.isLinux) {
|
||||
// Linux uses flutter_local_authentication
|
||||
return await FlutterLocalAuthentication().authenticate();
|
||||
} else {
|
||||
await LocalAuthentication().stopAuthentication();
|
||||
|
||||
@@ -130,14 +130,19 @@ class _AppLockState extends State<AppLock> with WidgetsBindingObserver {
|
||||
case '/lock-screen':
|
||||
return PageRouteBuilder(
|
||||
pageBuilder: (_, __, ___) => this._lockScreen,
|
||||
settings: settings,
|
||||
);
|
||||
case '/unlocked':
|
||||
return PageRouteBuilder(
|
||||
pageBuilder: (_, __, ___) =>
|
||||
this.widget.builder(settings.arguments),
|
||||
settings: settings,
|
||||
);
|
||||
}
|
||||
return PageRouteBuilder(pageBuilder: (_, __, ___) => this._lockScreen);
|
||||
return PageRouteBuilder(
|
||||
pageBuilder: (_, __, ___) => this._lockScreen,
|
||||
settings: settings,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -193,10 +198,18 @@ class _AppLockState extends State<AppLock> with WidgetsBindingObserver {
|
||||
});
|
||||
}
|
||||
|
||||
/// Manually show the [lockScreen].
|
||||
/// Show the [lockScreen] for automatic locking (app launch, background resume).
|
||||
Future<void> showLockScreen() {
|
||||
this._isLocked = true;
|
||||
return _navigatorKey.currentState!.pushNamed('/lock-screen');
|
||||
return _navigatorKey.currentState!
|
||||
.pushNamed('/lock-screen', arguments: {"manual": false});
|
||||
}
|
||||
|
||||
/// Show the [lockScreen] for user-initiated manual lock (no auto-auth on first frame).
|
||||
Future<void> showManualLockScreen() {
|
||||
this._isLocked = true;
|
||||
return _navigatorKey.currentState!
|
||||
.pushNamed('/lock-screen', arguments: {"manual": true});
|
||||
}
|
||||
|
||||
void _didUnlockOnAppLaunch(Object? args) {
|
||||
|
||||
@@ -38,6 +38,8 @@ class _LockScreenState extends State<LockScreen> with WidgetsBindingObserver {
|
||||
int remainingTimeInSeconds = 0;
|
||||
final _lockscreenSetting = LockScreenSettings.instance;
|
||||
late Brightness _platformBrightness;
|
||||
// Suppress auto-auth only for the initial manual presentation.
|
||||
bool _suppressAutoPrompt = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -46,7 +48,13 @@ class _LockScreenState extends State<LockScreen> with WidgetsBindingObserver {
|
||||
invalidAttemptCount = _lockscreenSetting.getInvalidAttemptCount();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||
_showLockScreen(source: "postFrameInit");
|
||||
final Map<String, dynamic>? args =
|
||||
ModalRoute.of(context)?.settings.arguments as Map<String, dynamic>?;
|
||||
final bool isManualPresentation = args?['manual'] as bool? ?? false;
|
||||
_suppressAutoPrompt = isManualPresentation;
|
||||
if (!isManualPresentation) {
|
||||
_showLockScreen(source: "postFrameInit");
|
||||
}
|
||||
});
|
||||
_platformBrightness =
|
||||
SchedulerBinding.instance.platformDispatcher.platformBrightness;
|
||||
@@ -71,7 +79,8 @@ class _LockScreenState extends State<LockScreen> with WidgetsBindingObserver {
|
||||
),
|
||||
body: GestureDetector(
|
||||
onTap: () {
|
||||
isTimerRunning ? null : _showLockScreen(source: "tap");
|
||||
if (isTimerRunning) return;
|
||||
_showLockScreen(source: "tap");
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
@@ -219,8 +228,7 @@ class _LockScreenState extends State<LockScreen> with WidgetsBindingObserver {
|
||||
DateTime.now().millisecondsSinceEpoch - lastAuthenticatingTime! <
|
||||
5000;
|
||||
if (!_hasAuthenticationFailed && !didAuthInLast5Seconds) {
|
||||
// Show the lock screen again only if the app is resuming from the
|
||||
// background, and not when the lock screen was explicitly dismissed
|
||||
// If there is a cooldown timer (after multiple failures), respect it
|
||||
if (_lockscreenSetting.getlastInvalidAttemptTime() >
|
||||
DateTime.now().millisecondsSinceEpoch &&
|
||||
!_isShowingLockScreen) {
|
||||
@@ -231,6 +239,9 @@ class _LockScreenState extends State<LockScreen> with WidgetsBindingObserver {
|
||||
startLockTimer(time);
|
||||
_showLockScreen(source: "lifeCycle");
|
||||
});
|
||||
} else if (!_suppressAutoPrompt) {
|
||||
// No cooldown: auto-prompt when app becomes active again
|
||||
_showLockScreen(source: "lifeCycle");
|
||||
}
|
||||
} else {
|
||||
_hasAuthenticationFailed = false; // Reset failure state
|
||||
@@ -242,6 +253,9 @@ class _LockScreenState extends State<LockScreen> with WidgetsBindingObserver {
|
||||
if (!_isShowingLockScreen) {
|
||||
_hasPlacedAppInBackground = true;
|
||||
_hasAuthenticationFailed = false; // reset failure state
|
||||
// If we suppressed the initial auto-prompt due to manual lock,
|
||||
// enable auto-prompt for the next resume after focus loss.
|
||||
_suppressAutoPrompt = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,18 +17,22 @@ class Network {
|
||||
late Dio _enteDio;
|
||||
|
||||
Future<void> init(BaseConfiguration configuration) async {
|
||||
final String ua = await userAgent();
|
||||
final bool isMobile = Platform.isAndroid || Platform.isIOS;
|
||||
String? ua;
|
||||
if (isMobile) {
|
||||
ua = await userAgent();
|
||||
}
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
final version = packageInfo.version;
|
||||
final packageName = packageInfo.packageName;
|
||||
final endpoint = configuration.getHttpEndpoint();
|
||||
final isMobile = Platform.isAndroid || Platform.isIOS;
|
||||
|
||||
_dio = Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: Duration(milliseconds: kConnectTimeout),
|
||||
headers: {
|
||||
HttpHeaders.userAgentHeader: isMobile ? ua : Platform.operatingSystem,
|
||||
HttpHeaders.userAgentHeader:
|
||||
isMobile ? ua! : Platform.operatingSystem,
|
||||
'X-Client-Version': version,
|
||||
'X-Client-Package': packageName,
|
||||
},
|
||||
@@ -41,7 +45,7 @@ class Network {
|
||||
connectTimeout: Duration(milliseconds: kConnectTimeout),
|
||||
headers: {
|
||||
if (isMobile)
|
||||
HttpHeaders.userAgentHeader: ua
|
||||
HttpHeaders.userAgentHeader: ua!
|
||||
else
|
||||
HttpHeaders.userAgentHeader: Platform.operatingSystem,
|
||||
'X-Client-Version': version,
|
||||
|
||||
166
rust/CLAUDE.md
Normal file
166
rust/CLAUDE.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## 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. Format code
|
||||
cargo fmt
|
||||
|
||||
# 2. Check for clippy warnings (THIS MUST PASS - CI fails on any warning)
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
# If this fails, fix the warnings manually (not all can be auto-fixed)
|
||||
|
||||
# 3. Build with warnings as errors (THIS MUST PASS - matches CI environment)
|
||||
RUSTFLAGS="-D warnings" cargo build
|
||||
|
||||
# 4. Verify formatting is correct (THIS MUST PASS - CI checks this)
|
||||
cargo fmt --check
|
||||
```
|
||||
|
||||
**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 clippy 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
|
||||
|
||||
### Build and Test
|
||||
|
||||
```bash
|
||||
# Format code (required before commits)
|
||||
cargo fmt
|
||||
|
||||
# Run linter (must pass for CI)
|
||||
cargo clippy --all-targets --all-features
|
||||
|
||||
# Build the project
|
||||
cargo build
|
||||
|
||||
# Run in development
|
||||
cargo run -- <command>
|
||||
|
||||
# Build release version
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
### CI Requirements
|
||||
|
||||
The codebase must pass the GitHub Actions workflow at `../.github/workflows/rust-lint.yml` which runs:
|
||||
|
||||
1. `cargo fmt --check` - Code formatting check
|
||||
2. `cargo clippy --all-targets --all-features` - Linting with all warnings as errors (RUSTFLAGS: -D warnings)
|
||||
3. `cargo build` - Build verification
|
||||
|
||||
**Important FFI Note**: When working with libsodium FFI bindings, always use `std::ffi::c_char` for C char pointer casts (e.g., `as *const std::ffi::c_char`), NOT raw `i8` casts. The CI environment may have different type expectations than local development.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
CLI for ente.io with end-to-end encryption, multi-account and multi-app support.
|
||||
|
||||
### Core Modules
|
||||
|
||||
**`api/`** - HTTP client for ente.io API
|
||||
|
||||
- `client.rs`: Base HTTP client with auth token management
|
||||
- `auth.rs`: SRP (Secure Remote Password) authentication implementation
|
||||
- `methods.rs`: API method implementations (collections, files, user info)
|
||||
- `models.rs`: API request/response data structures
|
||||
- `retry.rs`: Retry logic with exponential backoff for rate limiting
|
||||
|
||||
**`crypto/`** - Cryptographic operations using libsodium
|
||||
|
||||
- `argon.rs`: Argon2id key derivation
|
||||
- `chacha.rs`: ChaCha20-Poly1305 encryption/decryption
|
||||
- `stream.rs`: XChaCha20-Poly1305 streaming decryption for large files
|
||||
- `kdf.rs`: Blake2b key derivation
|
||||
- All crypto MUST use `libsodium-sys-stable` (statically linked)
|
||||
|
||||
**`storage/`** - SQLite persistence layer
|
||||
|
||||
- `schema.rs`: Database schema for accounts, files, collections
|
||||
- `account.rs`: Account CRUD operations with multi-app support
|
||||
- `sync.rs`: Sync state management (last sync times, file tracking)
|
||||
- `config.rs`: Key-value configuration store
|
||||
- Uses `rusqlite` with bundled SQLite for portability
|
||||
|
||||
**`cli/`** - Command-line argument parsing
|
||||
|
||||
- `account.rs`: Account command argument structures
|
||||
- `export.rs`: Export command argument structures
|
||||
- `version.rs`: Version information constants
|
||||
- Uses `clap` for argument parsing
|
||||
|
||||
**`commands/`** - Command implementation logic
|
||||
|
||||
- `account.rs`: Account management implementation (add, list, update, get-token)
|
||||
- `export.rs`: Photo export orchestration with filtering support
|
||||
- `sync.rs`: Synchronization logic execution
|
||||
|
||||
**`models/`** - Data structures
|
||||
|
||||
- `account.rs`: Account model with encrypted credentials
|
||||
- `file.rs`: File metadata and encryption info
|
||||
- `collection.rs`: Albums/collections with sharing support
|
||||
- `metadata.rs`: Decrypted file metadata (title, timestamps, location)
|
||||
- `filter.rs`: Export filtering options (shared/hidden albums, emails)
|
||||
- `error.rs`: Error types using `thiserror`
|
||||
|
||||
**`sync/`** - Synchronization engine
|
||||
|
||||
- `engine.rs`: Core sync orchestration and state management
|
||||
- `files.rs`: File synchronization logic
|
||||
- `download.rs`: File download and decryption implementation
|
||||
|
||||
**`utils/`** - Utility functions
|
||||
|
||||
- `mod.rs`: Config directory management with platform-specific defaults
|
||||
|
||||
### Key Implementation Details
|
||||
|
||||
1. **Authentication Flow**: SRP-based authentication storing tokens in SQLite, supports multiple apps (photos, locker, auth)
|
||||
2. **Multi-Account Support**: SQLite-based storage with per-account/per-app token management
|
||||
3. **File Organization**: Export to `export_dir/AlbumName/filename` structure (files in "Uncategorized" if no album)
|
||||
4. **Export Filtering**: Support for filtering by shared/hidden albums and specific user emails
|
||||
5. **Encryption**: Files encrypted with ChaCha20-Poly1305, streaming decryption for large files, keys derived via Argon2/Blake2b
|
||||
6. **Async Runtime**: Uses tokio with full features for concurrent operations
|
||||
7. **Error Handling**: Propagate errors with `?` operator, use `anyhow` for context
|
||||
8. **Configuration**: Platform-specific config directories (Linux: ~/.config/ente-cli, macOS: ~/Library/Application Support/ente-cli, Windows: %APPDATA%/ente-cli)
|
||||
|
||||
## Security Guidelines
|
||||
|
||||
**NEVER commit sensitive information:**
|
||||
|
||||
- No real email addresses, usernames, or account IDs in code or documentation
|
||||
- No authentication tokens, API keys, or passwords (even for test accounts)
|
||||
- No debug logs that output credentials, keys, or personal information
|
||||
- Use generic examples like "user@example.com" in documentation
|
||||
- Remove all `log::debug!` statements that print sensitive data before committing
|
||||
- Avoid logging encrypted keys, nonces, or tokens even in encrypted form
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `ENTE_CLI_CONFIG_DIR`: Override default config directory
|
||||
- `RUST_LOG`: Set log level (debug, info, warn, error, trace)
|
||||
4157
rust/Cargo.lock
generated
4157
rust/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -8,3 +8,54 @@ repository = "https://github.com/ente-io/ente"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
[dependencies]
|
||||
# CLI and configuration
|
||||
clap = { version = "4.5", features = ["derive", "env"] }
|
||||
config = "0.14"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde_yaml = "0.9"
|
||||
toml = "0.8"
|
||||
|
||||
# Async runtime and networking
|
||||
tokio = { version = "1.41", features = ["full"] }
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
futures = "0.3"
|
||||
|
||||
# Cryptography - ONLY libsodium (statically linked)
|
||||
libsodium-sys-stable = "1.20"
|
||||
base64 = "0.22"
|
||||
hex = "0.4"
|
||||
zeroize = { version = "1.8", features = ["derive"] }
|
||||
|
||||
# SRP authentication
|
||||
srp = "0.6"
|
||||
srp6 = "1.0.0-beta.1"
|
||||
num-bigint = { version = "0.4", features = ["rand"] }
|
||||
sha2 = "0.10"
|
||||
urlencoding = "2.1"
|
||||
rand = "0.8"
|
||||
|
||||
# Storage
|
||||
rusqlite = { version = "0.32", features = ["bundled", "serde_json"] }
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "migrate"], optional = true }
|
||||
|
||||
# Utilities
|
||||
thiserror = "2.0"
|
||||
anyhow = "1.0"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
dirs = "5.0"
|
||||
chrono = "0.4"
|
||||
uuid = { version = "1.11", features = ["serde", "v4"] }
|
||||
rpassword = "7.3"
|
||||
indicatif = "0.17"
|
||||
dialoguer = "0.11"
|
||||
|
||||
# File operations
|
||||
walkdir = "2.5"
|
||||
zip = "2.2"
|
||||
tempfile = "3.14"
|
||||
|
||||
[dev-dependencies]
|
||||
mockito = "1.6"
|
||||
tempdir = "0.3"
|
||||
|
||||
208
rust/src/api/auth.rs
Normal file
208
rust/src/api/auth.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
use crate::api::client::ApiClient;
|
||||
use crate::api::models::{
|
||||
AuthResponse, CreateSrpSessionRequest, CreateSrpSessionResponse, GetSrpAttributesResponse,
|
||||
SendOtpRequest, SrpAttributes, VerifyEmailRequest, VerifySrpSessionRequest, VerifyTotpRequest,
|
||||
};
|
||||
use crate::crypto::{derive_argon_key, derive_login_key};
|
||||
use crate::models::error::Result;
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use sha2::Sha256;
|
||||
use srp::client::SrpClient;
|
||||
use srp::groups::G_4096;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// SRP authentication implementation for Ente API
|
||||
pub struct AuthClient<'a> {
|
||||
api: &'a ApiClient,
|
||||
}
|
||||
|
||||
impl<'a> AuthClient<'a> {
|
||||
pub fn new(api: &'a ApiClient) -> Self {
|
||||
Self { api }
|
||||
}
|
||||
|
||||
/// Get SRP attributes for a user by email
|
||||
pub async fn get_srp_attributes(&self, email: &str) -> Result<SrpAttributes> {
|
||||
let url = format!("/users/srp/attributes?email={}", urlencoding::encode(email));
|
||||
let response: GetSrpAttributesResponse = self.api.get(&url, None).await?;
|
||||
Ok(response.attributes)
|
||||
}
|
||||
|
||||
/// Create SRP session - first step of SRP authentication
|
||||
pub async fn create_srp_session(
|
||||
&self,
|
||||
srp_user_id: &Uuid,
|
||||
client_public: &[u8],
|
||||
) -> Result<CreateSrpSessionResponse> {
|
||||
let request = CreateSrpSessionRequest {
|
||||
srp_user_id: srp_user_id.to_string(),
|
||||
srp_a: STANDARD.encode(client_public),
|
||||
};
|
||||
|
||||
self.api
|
||||
.post("/users/srp/create-session", &request, None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Verify SRP session - final step of SRP authentication
|
||||
pub async fn verify_srp_session(
|
||||
&self,
|
||||
srp_user_id: &Uuid,
|
||||
session_id: &Uuid,
|
||||
client_proof: &[u8],
|
||||
) -> Result<AuthResponse> {
|
||||
let request = VerifySrpSessionRequest {
|
||||
srp_user_id: srp_user_id.to_string(),
|
||||
session_id: session_id.to_string(),
|
||||
srp_m1: STANDARD.encode(client_proof),
|
||||
};
|
||||
|
||||
log::debug!(
|
||||
"Sending verify-session request for session_id: {}",
|
||||
request.session_id
|
||||
);
|
||||
|
||||
self.api
|
||||
.post("/users/srp/verify-session", &request, None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Complete SRP authentication flow
|
||||
pub async fn login_with_srp(
|
||||
&self,
|
||||
email: &str,
|
||||
password: &str,
|
||||
) -> Result<(AuthResponse, Vec<u8>)> {
|
||||
use rand::RngCore;
|
||||
|
||||
// Step 1: Get SRP attributes
|
||||
let srp_attrs = self.get_srp_attributes(email).await?;
|
||||
|
||||
// Step 2: Derive key encryption key from password
|
||||
let key_enc_key = derive_argon_key(
|
||||
password,
|
||||
&srp_attrs.kek_salt,
|
||||
srp_attrs.mem_limit as u32,
|
||||
srp_attrs.ops_limit as u32,
|
||||
)?;
|
||||
|
||||
// Step 3: Derive login key
|
||||
let login_key = derive_login_key(&key_enc_key)?;
|
||||
|
||||
// Step 4: Initialize SRP client
|
||||
let srp_salt = STANDARD.decode(&srp_attrs.srp_salt)?;
|
||||
// Use the UUID string directly as bytes (matching TypeScript's Buffer.from(srpUserID))
|
||||
let identity = srp_attrs.srp_user_id.to_string().into_bytes();
|
||||
|
||||
// Create SRP client with 4096-bit group (matching Go's srp.GetParams(4096))
|
||||
let client = SrpClient::<Sha256>::new(&G_4096);
|
||||
|
||||
// Generate random ephemeral private key
|
||||
let mut a = vec![0u8; 64];
|
||||
rand::thread_rng().fill_bytes(&mut a);
|
||||
|
||||
// Compute public ephemeral
|
||||
let a_pub = client.compute_public_ephemeral(&a);
|
||||
|
||||
// Step 5: Create SRP session
|
||||
log::debug!("Creating SRP session...");
|
||||
let session = self
|
||||
.create_srp_session(&srp_attrs.srp_user_id, &a_pub)
|
||||
.await?;
|
||||
log::debug!("Session created successfully: {}", session.session_id);
|
||||
|
||||
// Add a small delay to avoid potential rate limiting
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
|
||||
// Step 6: Process server's public key and generate proof
|
||||
let server_public = STANDARD.decode(&session.srp_b)?;
|
||||
|
||||
// Process the server's response and generate client proof
|
||||
// The srp crate expects: a, username, password, salt, b_pub
|
||||
// But Ente uses the login_key (derived from password) as the password for SRP
|
||||
let verifier = client
|
||||
.process_reply(&a, &identity, &login_key, &srp_salt, &server_public)
|
||||
.map_err(|e| {
|
||||
crate::models::error::Error::AuthenticationFailed(format!(
|
||||
"SRP client process failed: {e:?}"
|
||||
))
|
||||
})?;
|
||||
|
||||
// Step 7: Verify session with proof
|
||||
let proof = verifier.proof();
|
||||
|
||||
let auth_response = self
|
||||
.verify_srp_session(&srp_attrs.srp_user_id, &session.session_id, proof)
|
||||
.await?;
|
||||
|
||||
// TODO: Verify server proof if provided
|
||||
// if let Some(srp_m2) = &auth_response.srp_m2 {
|
||||
// let server_proof = STANDARD.decode(srp_m2)?;
|
||||
// verifier.verify_server(&server_proof).map_err(|_| {
|
||||
// crate::models::error::Error::AuthenticationFailed(
|
||||
// "Server proof verification failed".to_string()
|
||||
// )
|
||||
// })?;
|
||||
// }
|
||||
|
||||
Ok((auth_response, key_enc_key))
|
||||
}
|
||||
|
||||
/// Send OTP for email verification
|
||||
pub async fn send_login_otp(&self, email: &str) -> Result<()> {
|
||||
let request = SendOtpRequest {
|
||||
email: email.to_string(),
|
||||
purpose: "login".to_string(),
|
||||
};
|
||||
|
||||
let _: serde_json::Value = self.api.post("/users/ott", &request, None).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verify email with OTP
|
||||
pub async fn verify_email(&self, email: &str, otp: &str) -> Result<AuthResponse> {
|
||||
let request = VerifyEmailRequest {
|
||||
email: email.to_string(),
|
||||
ott: otp.to_string(),
|
||||
};
|
||||
|
||||
self.api.post("/users/verify-email", &request, None).await
|
||||
}
|
||||
|
||||
/// Verify TOTP for two-factor authentication
|
||||
pub async fn verify_totp(&self, session_id: &str, code: &str) -> Result<AuthResponse> {
|
||||
let request = VerifyTotpRequest {
|
||||
session_id: session_id.to_string(),
|
||||
code: code.to_string(),
|
||||
};
|
||||
|
||||
self.api
|
||||
.post("/users/two-factor/verify", &request, None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Check passkey verification status
|
||||
pub async fn check_passkey_status(&self, session_id: &str) -> Result<AuthResponse> {
|
||||
let url = format!("/users/two-factor/passkeys/get-token?sessionID={session_id}");
|
||||
self.api.get(&url, None).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::crypto::{derive_argon_key, derive_login_key};
|
||||
|
||||
#[test]
|
||||
fn test_login_key_derivation() {
|
||||
// Test that login key derivation matches expected output
|
||||
let password = "test_password";
|
||||
let salt = b"test_salt_16bytes";
|
||||
|
||||
let key = derive_argon_key(password, &STANDARD.encode(salt), 4, 3).unwrap();
|
||||
assert_eq!(key.len(), 32);
|
||||
|
||||
let login_key = derive_login_key(&key).unwrap();
|
||||
assert_eq!(login_key.len(), 32);
|
||||
}
|
||||
}
|
||||
353
rust/src/api/client.rs
Normal file
353
rust/src/api/client.rs
Normal file
@@ -0,0 +1,353 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::api::retry::RetryConfig;
|
||||
use crate::models::error::{Error, Result};
|
||||
use reqwest::{Client, RequestBuilder, Response, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
const ENTE_API_ENDPOINT: &str = "https://api.ente.io";
|
||||
const TOKEN_HEADER: &str = "X-Auth-Token";
|
||||
const CLIENT_PKG_HEADER: &str = "X-Client-Package";
|
||||
const CLIENT_PACKAGE: &str = "io.ente.cli";
|
||||
|
||||
/// Maximum number of retry attempts for failed requests
|
||||
const MAX_RETRIES: u32 = 3;
|
||||
/// Initial retry delay in milliseconds
|
||||
const INITIAL_RETRY_DELAY_MS: u64 = 1000;
|
||||
/// Maximum retry delay in milliseconds
|
||||
const MAX_RETRY_DELAY_MS: u64 = 20000;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ApiError {
|
||||
pub code: Option<String>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ApiClient {
|
||||
client: Client,
|
||||
download_client: Client,
|
||||
pub(crate) base_url: String,
|
||||
/// Token storage for multi-account support: account_id -> token
|
||||
tokens: Arc<RwLock<HashMap<String, String>>>,
|
||||
/// Retry configuration
|
||||
retry_config: RetryConfig,
|
||||
}
|
||||
|
||||
impl ApiClient {
|
||||
pub fn new(base_url: Option<String>) -> Result<Self> {
|
||||
// Main API client with standard timeout
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.user_agent(format!("ente-cli-rust/{}", env!("CARGO_PKG_VERSION")))
|
||||
.build()?;
|
||||
|
||||
// Download client with longer timeout and connection pool settings
|
||||
let download_client = Client::builder()
|
||||
.timeout(Duration::from_secs(300))
|
||||
.pool_idle_timeout(Duration::from_secs(90))
|
||||
.pool_max_idle_per_host(10)
|
||||
.user_agent(format!("ente-cli-rust/{}", env!("CARGO_PKG_VERSION")))
|
||||
.build()?;
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
download_client,
|
||||
base_url: base_url.unwrap_or_else(|| ENTE_API_ENDPOINT.to_string()),
|
||||
tokens: Arc::new(RwLock::new(HashMap::new())),
|
||||
retry_config: RetryConfig::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Add or update authentication token for an account
|
||||
pub fn add_token(&self, account_id: &str, token: &str) {
|
||||
let mut tokens = self.tokens.write().unwrap();
|
||||
tokens.insert(account_id.to_string(), token.to_string());
|
||||
}
|
||||
|
||||
/// Remove authentication token for an account
|
||||
pub fn remove_token(&self, account_id: &str) {
|
||||
let mut tokens = self.tokens.write().unwrap();
|
||||
tokens.remove(account_id);
|
||||
}
|
||||
|
||||
/// Get authentication token for an account
|
||||
pub fn get_token(&self, account_id: &str) -> Option<String> {
|
||||
let tokens = self.tokens.read().unwrap();
|
||||
tokens.get(account_id).cloned()
|
||||
}
|
||||
|
||||
/// Set retry configuration
|
||||
pub fn set_retry_config(&mut self, config: RetryConfig) {
|
||||
self.retry_config = config;
|
||||
}
|
||||
|
||||
/// Build a request with common headers
|
||||
fn build_request(&self, builder: RequestBuilder, account_id: Option<&str>) -> RequestBuilder {
|
||||
let mut req = builder.header(CLIENT_PKG_HEADER, CLIENT_PACKAGE);
|
||||
|
||||
// Add auth token if account_id is provided
|
||||
if let Some(id) = account_id {
|
||||
if let Some(token) = self.get_token(id) {
|
||||
log::debug!("Adding auth token for account {id}");
|
||||
req = req.header(TOKEN_HEADER, token);
|
||||
} else {
|
||||
log::warn!("No token found for account {id}");
|
||||
}
|
||||
}
|
||||
|
||||
req
|
||||
}
|
||||
|
||||
/// Execute request with retry logic
|
||||
async fn execute_with_retry(&self, request_builder: RequestBuilder) -> Result<Response> {
|
||||
let mut retry_count = 0;
|
||||
let mut delay_ms = INITIAL_RETRY_DELAY_MS;
|
||||
|
||||
loop {
|
||||
let req = request_builder
|
||||
.try_clone()
|
||||
.ok_or_else(|| Error::Generic("Failed to clone request for retry".to_string()))?;
|
||||
|
||||
match req.send().await {
|
||||
Ok(response) => {
|
||||
// Check if we should retry based on status code
|
||||
if (response.status() == StatusCode::TOO_MANY_REQUESTS
|
||||
|| response.status().is_server_error())
|
||||
&& retry_count < MAX_RETRIES
|
||||
{
|
||||
retry_count += 1;
|
||||
log::warn!(
|
||||
"Request failed with status {}, retry attempt {}/{}",
|
||||
response.status(),
|
||||
retry_count,
|
||||
MAX_RETRIES
|
||||
);
|
||||
|
||||
// Exponential backoff with jitter
|
||||
sleep(Duration::from_millis(delay_ms)).await;
|
||||
delay_ms = (delay_ms * 2).min(MAX_RETRY_DELAY_MS);
|
||||
continue;
|
||||
}
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
Err(e) => {
|
||||
if retry_count < MAX_RETRIES {
|
||||
retry_count += 1;
|
||||
log::warn!(
|
||||
"Request failed with error: {e}, retry attempt {retry_count}/{MAX_RETRIES}"
|
||||
);
|
||||
|
||||
sleep(Duration::from_millis(delay_ms)).await;
|
||||
delay_ms = (delay_ms * 2).min(MAX_RETRY_DELAY_MS);
|
||||
continue;
|
||||
}
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a GET request
|
||||
pub async fn get<T>(&self, path: &str, account_id: Option<&str>) -> Result<T>
|
||||
where
|
||||
T: for<'de> Deserialize<'de>,
|
||||
{
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
let request = self.client.get(&url);
|
||||
let request = self.build_request(request, account_id);
|
||||
|
||||
let response = self.execute_with_retry(request).await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
|
||||
// Try to parse as JSON to get error details
|
||||
if let Ok(error_json) = serde_json::from_str::<serde_json::Value>(&error_text) {
|
||||
log::error!(
|
||||
"API error: status={}, body={}",
|
||||
status,
|
||||
serde_json::to_string_pretty(&error_json).unwrap_or(error_text.clone())
|
||||
);
|
||||
} else {
|
||||
log::error!("API error: status={status}, body={error_text}");
|
||||
}
|
||||
|
||||
return Err(Error::Generic(format!(
|
||||
"API error ({status}): {error_text}"
|
||||
)));
|
||||
}
|
||||
|
||||
let text = response.text().await?;
|
||||
serde_json::from_str(&text).map_err(|e| {
|
||||
log::error!("Failed to deserialize response: {e}");
|
||||
log::error!(
|
||||
"Response text (first 1000 chars): {}",
|
||||
&text[..1000.min(text.len())]
|
||||
);
|
||||
Error::Generic(format!("Deserialization failed: {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Make a POST request
|
||||
pub async fn post<T, B>(&self, path: &str, body: &B, account_id: Option<&str>) -> Result<T>
|
||||
where
|
||||
T: for<'de> Deserialize<'de>,
|
||||
B: Serialize,
|
||||
{
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
|
||||
// Debug log the JSON being sent
|
||||
if path.contains("verify-session") {
|
||||
log::debug!(
|
||||
"POST {} with JSON: {}",
|
||||
url,
|
||||
serde_json::to_string_pretty(body)?
|
||||
);
|
||||
}
|
||||
|
||||
let request = self.client.post(&url).json(body);
|
||||
let request = self.build_request(request, account_id);
|
||||
|
||||
let response = self.execute_with_retry(request).await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
|
||||
// Try to parse as JSON to get error details
|
||||
if let Ok(error_json) = serde_json::from_str::<serde_json::Value>(&error_text) {
|
||||
log::error!(
|
||||
"API error: status={}, body={}",
|
||||
status,
|
||||
serde_json::to_string_pretty(&error_json).unwrap_or(error_text.clone())
|
||||
);
|
||||
} else {
|
||||
log::error!("API error: status={status}, body={error_text}");
|
||||
}
|
||||
|
||||
return Err(Error::Generic(format!(
|
||||
"API error ({status}): {error_text}"
|
||||
)));
|
||||
}
|
||||
|
||||
let text = response.text().await?;
|
||||
serde_json::from_str(&text).map_err(|e| {
|
||||
log::error!("Failed to deserialize response: {e}");
|
||||
log::error!(
|
||||
"Response text (first 1000 chars): {}",
|
||||
&text[..1000.min(text.len())]
|
||||
);
|
||||
Error::Generic(format!("Deserialization failed: {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Make a PUT request
|
||||
pub async fn put<T, B>(&self, path: &str, body: &B, account_id: Option<&str>) -> Result<T>
|
||||
where
|
||||
T: for<'de> Deserialize<'de>,
|
||||
B: Serialize,
|
||||
{
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
let request = self.client.put(&url).json(body);
|
||||
let request = self.build_request(request, account_id);
|
||||
|
||||
let response = self.execute_with_retry(request).await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
|
||||
// Try to parse as JSON to get error details
|
||||
if let Ok(error_json) = serde_json::from_str::<serde_json::Value>(&error_text) {
|
||||
log::error!(
|
||||
"API error: status={}, body={}",
|
||||
status,
|
||||
serde_json::to_string_pretty(&error_json).unwrap_or(error_text.clone())
|
||||
);
|
||||
} else {
|
||||
log::error!("API error: status={status}, body={error_text}");
|
||||
}
|
||||
|
||||
return Err(Error::Generic(format!(
|
||||
"API error ({status}): {error_text}"
|
||||
)));
|
||||
}
|
||||
|
||||
let text = response.text().await?;
|
||||
serde_json::from_str(&text).map_err(|e| {
|
||||
log::error!("Failed to deserialize response: {e}");
|
||||
log::error!(
|
||||
"Response text (first 1000 chars): {}",
|
||||
&text[..1000.min(text.len())]
|
||||
);
|
||||
Error::Generic(format!("Deserialization failed: {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Make a DELETE request
|
||||
pub async fn delete(&self, path: &str, account_id: Option<&str>) -> Result<()> {
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
let request = self.client.delete(&url);
|
||||
let request = self.build_request(request, account_id);
|
||||
|
||||
let response = self.execute_with_retry(request).await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
|
||||
// Try to parse as JSON to get error details
|
||||
if let Ok(error_json) = serde_json::from_str::<serde_json::Value>(&error_text) {
|
||||
log::error!(
|
||||
"API error: status={}, body={}",
|
||||
status,
|
||||
serde_json::to_string_pretty(&error_json).unwrap_or(error_text.clone())
|
||||
);
|
||||
} else {
|
||||
log::error!("API error: status={status}, body={error_text}");
|
||||
}
|
||||
|
||||
return Err(Error::Generic(format!(
|
||||
"API error ({status}): {error_text}"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download a file with the download client
|
||||
pub async fn download_file(&self, url: &str, account_id: Option<&str>) -> Result<Vec<u8>> {
|
||||
let request = self.download_client.get(url);
|
||||
let request = self.build_request(request, account_id);
|
||||
|
||||
let response = self.execute_with_retry(request).await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(Error::Generic(format!("Download error: {error_text}")));
|
||||
}
|
||||
|
||||
Ok(response.bytes().await?.to_vec())
|
||||
}
|
||||
}
|
||||
177
rust/src/api/methods.rs
Normal file
177
rust/src/api/methods.rs
Normal file
@@ -0,0 +1,177 @@
|
||||
use crate::api::client::ApiClient;
|
||||
use crate::api::models::{
|
||||
Collection, File, GetCollectionsResponse, GetDiffResponse, GetFileResponse, GetFilesResponse,
|
||||
GetThumbnailUrlResponse, UserDetails,
|
||||
};
|
||||
use crate::models::error::Result;
|
||||
|
||||
/// API methods for interacting with Ente services
|
||||
pub struct ApiMethods<'a> {
|
||||
api: &'a ApiClient,
|
||||
}
|
||||
|
||||
impl<'a> ApiMethods<'a> {
|
||||
pub fn new(api: &'a ApiClient) -> Self {
|
||||
Self { api }
|
||||
}
|
||||
|
||||
// ========== User Methods ==========
|
||||
|
||||
/// Get user details including subscription and storage info
|
||||
pub async fn get_user_details(&self, account_id: &str) -> Result<UserDetails> {
|
||||
self.api.get("/users/details", Some(account_id)).await
|
||||
}
|
||||
|
||||
// ========== Collection Methods ==========
|
||||
|
||||
/// Get all collections (albums) for the authenticated user
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `account_id` - The account identifier for authentication
|
||||
/// * `since_time` - Unix timestamp in microseconds to get collections modified after this time (0 for all)
|
||||
pub async fn get_collections(
|
||||
&self,
|
||||
account_id: &str,
|
||||
since_time: i64,
|
||||
) -> Result<Vec<Collection>> {
|
||||
let url = format!("/collections/v2?sinceTime={since_time}");
|
||||
let response: GetCollectionsResponse = self.api.get(&url, Some(account_id)).await?;
|
||||
Ok(response.collections)
|
||||
}
|
||||
|
||||
/// Get a specific collection by ID
|
||||
pub async fn get_collection(&self, account_id: &str, collection_id: i64) -> Result<Collection> {
|
||||
let url = format!("/collections/{collection_id}");
|
||||
self.api.get(&url, Some(account_id)).await
|
||||
}
|
||||
|
||||
// ========== File Methods ==========
|
||||
|
||||
/// Get files from a specific collection with pagination
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `account_id` - The account identifier for authentication
|
||||
/// * `collection_id` - The collection ID to fetch files from
|
||||
/// * `since_time` - Unix timestamp in microseconds to get files modified after this time
|
||||
///
|
||||
/// # Returns
|
||||
/// A tuple of (files, has_more) where has_more indicates if there are more files to fetch
|
||||
pub async fn get_collection_files(
|
||||
&self,
|
||||
account_id: &str,
|
||||
collection_id: i64,
|
||||
since_time: i64,
|
||||
) -> Result<(Vec<File>, bool)> {
|
||||
let url =
|
||||
format!("/collections/v2/diff?collectionID={collection_id}&sinceTime={since_time}");
|
||||
let response: GetFilesResponse = self.api.get(&url, Some(account_id)).await?;
|
||||
Ok((response.diff, response.has_more))
|
||||
}
|
||||
|
||||
/// Get a specific file by ID
|
||||
pub async fn get_file(
|
||||
&self,
|
||||
account_id: &str,
|
||||
collection_id: i64,
|
||||
file_id: i64,
|
||||
) -> Result<File> {
|
||||
let url = format!("/collections/file?collectionID={collection_id}&fileID={file_id}");
|
||||
let response: GetFileResponse = self.api.get(&url, Some(account_id)).await?;
|
||||
Ok(response.file)
|
||||
}
|
||||
|
||||
/// Get all files across all collections (for incremental sync)
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `account_id` - The account identifier for authentication
|
||||
/// * `since_time` - Unix timestamp in microseconds to get files modified after this time
|
||||
/// * `limit` - Maximum number of files to return (typically 500)
|
||||
///
|
||||
/// # Returns
|
||||
/// A tuple of (files, has_more) where has_more indicates if there are more files to fetch
|
||||
pub async fn get_diff(
|
||||
&self,
|
||||
account_id: &str,
|
||||
since_time: i64,
|
||||
limit: i32,
|
||||
) -> Result<(Vec<File>, bool)> {
|
||||
let url = format!("/diff?sinceTime={since_time}&limit={limit}");
|
||||
let response: GetDiffResponse = self.api.get(&url, Some(account_id)).await?;
|
||||
Ok((response.diff, response.has_more))
|
||||
}
|
||||
|
||||
/// Get download URL for a file
|
||||
pub async fn get_file_url(&self, _account_id: &str, file_id: i64) -> Result<String> {
|
||||
// Check if we're using the default API endpoint
|
||||
let base_url = &self.api.base_url;
|
||||
if base_url == "https://api.ente.io" {
|
||||
// Use the CDN URL for production
|
||||
Ok(format!("https://files.ente.io/?fileID={file_id}"))
|
||||
} else {
|
||||
// For custom/dev environments, use direct download URL
|
||||
// The Go implementation shows this is the pattern
|
||||
Ok(format!("{base_url}/files/download/{file_id}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Get thumbnail URL for a file
|
||||
pub async fn get_thumbnail_url(&self, account_id: &str, file_id: i64) -> Result<String> {
|
||||
let url = format!("/files/preview/{file_id}");
|
||||
let response: GetThumbnailUrlResponse = self.api.get(&url, Some(account_id)).await?;
|
||||
Ok(response.url)
|
||||
}
|
||||
|
||||
/// Download file content
|
||||
pub async fn download_file(&self, account_id: &str, file_id: i64) -> Result<Vec<u8>> {
|
||||
let url = self.get_file_url(account_id, file_id).await?;
|
||||
self.api.download_file(&url, Some(account_id)).await
|
||||
}
|
||||
|
||||
/// Download thumbnail content
|
||||
pub async fn download_thumbnail(&self, account_id: &str, file_id: i64) -> Result<Vec<u8>> {
|
||||
let url = self.get_thumbnail_url(account_id, file_id).await?;
|
||||
self.api.download_file(&url, Some(account_id)).await
|
||||
}
|
||||
|
||||
// ========== Trash Methods ==========
|
||||
|
||||
/// Get deleted files
|
||||
pub async fn get_trash(&self, account_id: &str, since_time: i64) -> Result<(Vec<File>, bool)> {
|
||||
let url = format!("/trash/v2?sinceTime={since_time}");
|
||||
let response: GetDiffResponse = self.api.get(&url, Some(account_id)).await?;
|
||||
Ok((response.diff, response.has_more))
|
||||
}
|
||||
|
||||
/// Permanently delete files from trash
|
||||
pub async fn delete_from_trash(&self, account_id: &str, file_ids: &[i64]) -> Result<()> {
|
||||
let body = serde_json::json!({
|
||||
"fileIDs": file_ids
|
||||
});
|
||||
let _: serde_json::Value = self
|
||||
.api
|
||||
.post("/trash/delete", &body, Some(account_id))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Empty all trash
|
||||
pub async fn empty_trash(&self, account_id: &str) -> Result<()> {
|
||||
self.api.delete("/trash/empty", Some(account_id)).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_file_url_generation() {
|
||||
let api = ApiClient::new(None).unwrap();
|
||||
let methods = ApiMethods::new(&api);
|
||||
|
||||
// For production endpoint, should use CDN
|
||||
let url = methods.get_file_url("test", 12345).await;
|
||||
assert!(url.is_ok());
|
||||
assert!(url.unwrap().contains("files.ente.io"));
|
||||
}
|
||||
}
|
||||
10
rust/src/api/mod.rs
Normal file
10
rust/src/api/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
pub mod auth;
|
||||
pub mod client;
|
||||
pub mod methods;
|
||||
pub mod models;
|
||||
pub mod retry;
|
||||
|
||||
pub use auth::AuthClient;
|
||||
pub use client::ApiClient;
|
||||
pub use methods::ApiMethods;
|
||||
pub use models::*;
|
||||
323
rust/src/api/models.rs
Normal file
323
rust/src/api/models.rs
Normal file
@@ -0,0 +1,323 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
// ========== Authentication Models ==========
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct SrpAttributes {
|
||||
#[serde(rename = "srpUserID")]
|
||||
pub srp_user_id: Uuid,
|
||||
#[serde(rename = "srpSalt")]
|
||||
pub srp_salt: String,
|
||||
#[serde(rename = "memLimit")]
|
||||
pub mem_limit: i32,
|
||||
#[serde(rename = "opsLimit")]
|
||||
pub ops_limit: i32,
|
||||
#[serde(rename = "kekSalt")]
|
||||
pub kek_salt: String,
|
||||
#[serde(rename = "isEmailMFAEnabled")]
|
||||
pub is_email_mfa_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetSrpAttributesResponse {
|
||||
pub attributes: SrpAttributes,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateSrpSessionRequest {
|
||||
#[serde(rename = "srpUserID")]
|
||||
pub srp_user_id: String,
|
||||
#[serde(rename = "srpA")]
|
||||
pub srp_a: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateSrpSessionResponse {
|
||||
#[serde(rename = "sessionID")]
|
||||
pub session_id: Uuid,
|
||||
#[serde(rename = "srpB")]
|
||||
pub srp_b: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct VerifySrpSessionRequest {
|
||||
#[serde(rename = "srpUserID")]
|
||||
pub srp_user_id: String,
|
||||
#[serde(rename = "sessionID")]
|
||||
pub session_id: String,
|
||||
#[serde(rename = "srpM1")]
|
||||
pub srp_m1: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KeyAttributes {
|
||||
pub kek_salt: String,
|
||||
pub kek_hash: Option<String>,
|
||||
pub encrypted_key: String,
|
||||
pub key_decryption_nonce: String,
|
||||
pub public_key: String,
|
||||
pub encrypted_secret_key: String,
|
||||
pub secret_key_decryption_nonce: String,
|
||||
pub mem_limit: i32,
|
||||
pub ops_limit: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthResponse {
|
||||
pub id: i64,
|
||||
pub key_attributes: Option<KeyAttributes>,
|
||||
pub encrypted_token: Option<String>,
|
||||
pub token: Option<String>,
|
||||
pub two_factor_session_id: Option<String>,
|
||||
pub passkey_session_id: Option<String>,
|
||||
pub srp_m2: Option<String>,
|
||||
pub accounts_url: Option<String>,
|
||||
}
|
||||
|
||||
impl AuthResponse {
|
||||
pub fn is_mfa_required(&self) -> bool {
|
||||
self.two_factor_session_id.is_some()
|
||||
}
|
||||
|
||||
pub fn is_passkey_required(&self) -> bool {
|
||||
self.passkey_session_id.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SendOtpRequest {
|
||||
pub email: String,
|
||||
pub purpose: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct VerifyEmailRequest {
|
||||
pub email: String,
|
||||
pub ott: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VerifyTotpRequest {
|
||||
pub session_id: String,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
// ========== User Models ==========
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UserDetails {
|
||||
pub user: User,
|
||||
pub subscription: Subscription,
|
||||
pub family_data: Option<FamilyData>,
|
||||
pub storage: Storage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct User {
|
||||
pub id: i64,
|
||||
pub email: String,
|
||||
pub profile_data: Option<ProfileData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileData {
|
||||
pub can_disable_emails: bool,
|
||||
pub is_email_mfa_enabled: bool,
|
||||
pub is_two_factor_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Subscription {
|
||||
pub product_id: String,
|
||||
pub storage: i64,
|
||||
pub expiry_time: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FamilyData {
|
||||
pub members: Vec<FamilyMember>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FamilyMember {
|
||||
pub id: i64,
|
||||
pub email: String,
|
||||
pub usage: i64,
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Storage {
|
||||
pub used: i64,
|
||||
}
|
||||
|
||||
// ========== Collection Models ==========
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Collection {
|
||||
pub id: i64,
|
||||
pub owner: CollectionUser,
|
||||
pub encrypted_key: String,
|
||||
pub key_decryption_nonce: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub encrypted_name: Option<String>,
|
||||
pub name_decryption_nonce: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
pub collection_type: String,
|
||||
pub attributes: Option<CollectionAttributes>,
|
||||
pub sharees: Option<Vec<CollectionUser>>,
|
||||
#[serde(rename = "publicURLs")]
|
||||
pub public_urls: Option<Vec<PublicUrl>>,
|
||||
pub updation_time: i64,
|
||||
#[serde(default)]
|
||||
pub is_deleted: bool,
|
||||
pub magic_metadata: Option<MagicMetadata>,
|
||||
pub pub_magic_metadata: Option<MagicMetadata>,
|
||||
pub shared_magic_metadata: Option<MagicMetadata>,
|
||||
pub app: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct CollectionAttributes {
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PublicUrl {
|
||||
pub url: String,
|
||||
pub device_limit: i32,
|
||||
pub valid_till: i64,
|
||||
pub enable_download: bool,
|
||||
pub enable_collect: bool,
|
||||
pub password_enabled: bool,
|
||||
pub enable_join: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct CollectionUser {
|
||||
pub id: i64,
|
||||
#[serde(default)]
|
||||
pub email: String,
|
||||
pub name: Option<String>,
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct MagicMetadata {
|
||||
pub version: i32,
|
||||
pub count: i32,
|
||||
pub data: String,
|
||||
pub header: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GetCollectionsResponse {
|
||||
pub collections: Vec<Collection>,
|
||||
}
|
||||
|
||||
// ========== File Models ==========
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct File {
|
||||
pub id: i64,
|
||||
#[serde(rename = "ownerID")]
|
||||
pub owner_id: i64,
|
||||
#[serde(rename = "collectionID")]
|
||||
pub collection_id: i64,
|
||||
#[serde(rename = "collectionOwnerID")]
|
||||
pub collection_owner_id: Option<i64>,
|
||||
pub encrypted_key: String,
|
||||
pub key_decryption_nonce: String,
|
||||
pub file: FileAttributes,
|
||||
pub thumbnail: FileAttributes,
|
||||
pub metadata: FileAttributes,
|
||||
pub is_deleted: bool,
|
||||
pub updation_time: i64,
|
||||
pub magic_metadata: Option<MagicMetadata>,
|
||||
#[serde(rename = "pubMagicMetadata")]
|
||||
pub pub_magic_metadata: Option<MagicMetadata>,
|
||||
pub info: Option<FileInfo>,
|
||||
}
|
||||
|
||||
impl File {
|
||||
pub fn is_removed_from_album(&self) -> bool {
|
||||
self.is_deleted || self.file.encrypted_data == Some("-".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileAttributes {
|
||||
pub encrypted_data: Option<String>,
|
||||
pub decryption_header: String,
|
||||
pub size: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileInfo {
|
||||
pub file_size: Option<i64>,
|
||||
pub thumbnail_size: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetFilesRequest {
|
||||
pub collection_id: i64,
|
||||
pub since_time: i64,
|
||||
pub limit: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GetFilesResponse {
|
||||
pub diff: Vec<File>,
|
||||
#[serde(rename = "hasMore")]
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GetFileResponse {
|
||||
pub file: File,
|
||||
}
|
||||
|
||||
// ========== Diff/Sync Models ==========
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetDiffRequest {
|
||||
pub since_time: i64,
|
||||
pub limit: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GetDiffResponse {
|
||||
pub diff: Vec<File>,
|
||||
#[serde(rename = "hasMore")]
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
// ========== Download Models ==========
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GetFileUrlResponse {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GetThumbnailUrlResponse {
|
||||
pub url: String,
|
||||
}
|
||||
117
rust/src/api/retry.rs
Normal file
117
rust/src/api/retry.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use crate::Result;
|
||||
use reqwest::{Response, StatusCode};
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
/// Retry configuration
|
||||
pub struct RetryConfig {
|
||||
pub max_retries: u32,
|
||||
pub initial_delay: Duration,
|
||||
pub max_delay: Duration,
|
||||
pub exponential_base: f64,
|
||||
}
|
||||
|
||||
impl Default for RetryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_retries: 3,
|
||||
initial_delay: Duration::from_millis(500),
|
||||
max_delay: Duration::from_secs(30),
|
||||
exponential_base: 2.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a request with retry logic
|
||||
pub async fn with_retry<F, Fut>(config: &RetryConfig, mut operation: F) -> Result<Response>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<Response>>,
|
||||
{
|
||||
let mut attempt = 0;
|
||||
let mut delay = config.initial_delay;
|
||||
|
||||
loop {
|
||||
match operation().await {
|
||||
Ok(response) => {
|
||||
// Check if we should retry based on status code
|
||||
let status = response.status();
|
||||
|
||||
if status.is_success() {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
// Don't retry on client errors (except 429)
|
||||
if status.is_client_error() && status != StatusCode::TOO_MANY_REQUESTS {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
// Retry on 429 (rate limited) or 5xx errors
|
||||
if status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error() {
|
||||
attempt += 1;
|
||||
|
||||
if attempt > config.max_retries {
|
||||
log::warn!("Max retries ({}) exceeded for request", config.max_retries);
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
// Check for Retry-After header on 429 responses
|
||||
if status == StatusCode::TOO_MANY_REQUESTS
|
||||
&& let Some(retry_after) = response.headers().get("retry-after")
|
||||
&& let Ok(retry_str) = retry_after.to_str()
|
||||
&& let Ok(seconds) = retry_str.parse::<u64>()
|
||||
{
|
||||
delay = Duration::from_secs(seconds);
|
||||
log::info!("Rate limited, retrying after {} seconds", seconds);
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Request failed with status {}, retrying in {:?} (attempt {}/{})",
|
||||
status,
|
||||
delay,
|
||||
attempt,
|
||||
config.max_retries
|
||||
);
|
||||
|
||||
sleep(delay).await;
|
||||
|
||||
// Calculate next delay with exponential backoff
|
||||
delay = Duration::from_secs_f64(
|
||||
(delay.as_secs_f64() * config.exponential_base)
|
||||
.min(config.max_delay.as_secs_f64()),
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// For other status codes, don't retry
|
||||
return Ok(response);
|
||||
}
|
||||
Err(e) => {
|
||||
// Network errors should be retried
|
||||
attempt += 1;
|
||||
|
||||
if attempt > config.max_retries {
|
||||
log::error!("Max retries ({}) exceeded: {}", config.max_retries, e);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"Request failed: {}, retrying in {:?} (attempt {}/{})",
|
||||
e,
|
||||
delay,
|
||||
attempt,
|
||||
config.max_retries
|
||||
);
|
||||
|
||||
sleep(delay).await;
|
||||
|
||||
// Calculate next delay with exponential backoff
|
||||
delay = Duration::from_secs_f64(
|
||||
(delay.as_secs_f64() * config.exponential_base)
|
||||
.min(config.max_delay.as_secs_f64()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
62
rust/src/cli/account.rs
Normal file
62
rust/src/cli/account.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use clap::{Args, Subcommand};
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct AccountCommand {
|
||||
#[command(subcommand)]
|
||||
pub command: AccountSubcommands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum AccountSubcommands {
|
||||
/// List configured accounts
|
||||
List,
|
||||
|
||||
/// Login into existing account
|
||||
Add {
|
||||
/// Email address (optional - will prompt if not provided)
|
||||
#[arg(long)]
|
||||
email: Option<String>,
|
||||
|
||||
/// Password (optional - will prompt if not provided)
|
||||
#[arg(long)]
|
||||
password: Option<String>,
|
||||
|
||||
/// Specify the app (photos, locker, auth)
|
||||
#[arg(long, default_value = "photos")]
|
||||
app: String,
|
||||
|
||||
/// API endpoint (defaults to https://api.ente.io)
|
||||
#[arg(long, default_value = "https://api.ente.io")]
|
||||
endpoint: String,
|
||||
|
||||
/// Export directory path
|
||||
#[arg(long)]
|
||||
export_dir: Option<String>,
|
||||
},
|
||||
|
||||
/// Update an existing account's export directory
|
||||
Update {
|
||||
/// Email address of the account
|
||||
#[arg(long)]
|
||||
email: String,
|
||||
|
||||
/// Export directory path
|
||||
#[arg(long)]
|
||||
dir: String,
|
||||
|
||||
/// Specify the app (photos, locker, auth)
|
||||
#[arg(long, default_value = "photos")]
|
||||
app: String,
|
||||
},
|
||||
|
||||
/// Get token for an account for a specific app
|
||||
GetToken {
|
||||
/// Email address of the account
|
||||
#[arg(long)]
|
||||
email: String,
|
||||
|
||||
/// Specify the app (photos, locker, auth)
|
||||
#[arg(long, default_value = "photos")]
|
||||
app: String,
|
||||
},
|
||||
}
|
||||
24
rust/src/cli/export.rs
Normal file
24
rust/src/cli/export.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use clap::Args;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ExportCommand {
|
||||
/// Email of specific account to export (exports all if not specified)
|
||||
#[arg(long)]
|
||||
pub account: Option<String>,
|
||||
|
||||
/// Include shared albums (pass --shared=false to exclude)
|
||||
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
|
||||
pub shared: bool,
|
||||
|
||||
/// Include hidden albums (pass --hidden=false to exclude)
|
||||
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
|
||||
pub hidden: bool,
|
||||
|
||||
/// Comma-separated list of album names to export
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
pub albums: Option<Vec<String>>,
|
||||
|
||||
/// Comma-separated list of account emails to export from
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
pub emails: Option<Vec<String>>,
|
||||
}
|
||||
25
rust/src/cli/mod.rs
Normal file
25
rust/src/cli/mod.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
pub mod account;
|
||||
pub mod export;
|
||||
pub mod version;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "ente")]
|
||||
#[command(about = "CLI tool for exporting your photos from ente.io", long_about = None)]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Commands {
|
||||
/// Manage account settings
|
||||
Account(account::AccountCommand),
|
||||
|
||||
/// Export photos and files
|
||||
Export(export::ExportCommand),
|
||||
|
||||
/// Print version information
|
||||
Version,
|
||||
}
|
||||
1
rust/src/cli/version.rs
Normal file
1
rust/src/cli/version.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
372
rust/src/commands/account.rs
Normal file
372
rust/src/commands/account.rs
Normal file
@@ -0,0 +1,372 @@
|
||||
use crate::{
|
||||
api::{ApiClient, AuthClient},
|
||||
cli::account::{AccountCommand, AccountSubcommands},
|
||||
crypto::{decode_base64, sealed_box_open, secret_box_open},
|
||||
models::{
|
||||
account::{Account, AccountSecrets, App},
|
||||
error::Result,
|
||||
},
|
||||
storage::Storage,
|
||||
};
|
||||
use base64::Engine;
|
||||
use dialoguer::{Input, Password, Select};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub async fn handle_account_command(cmd: AccountCommand, storage: &Storage) -> Result<()> {
|
||||
match cmd.command {
|
||||
AccountSubcommands::List => list_accounts(storage).await,
|
||||
AccountSubcommands::Add {
|
||||
email,
|
||||
password,
|
||||
app,
|
||||
endpoint,
|
||||
export_dir,
|
||||
} => add_account(storage, email, password, app, endpoint, export_dir).await,
|
||||
AccountSubcommands::Update { email, dir, app } => {
|
||||
update_account(storage, &email, &dir, &app).await
|
||||
}
|
||||
AccountSubcommands::GetToken { email, app } => get_token(storage, &email, &app).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_accounts(storage: &Storage) -> Result<()> {
|
||||
let accounts = storage.accounts().list()?;
|
||||
|
||||
if accounts.is_empty() {
|
||||
println!("No accounts configured. Use 'ente account add' to add an account.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("\nConfigured accounts:\n");
|
||||
println!(
|
||||
"{:<30} {:<10} {:<30} {:<40}",
|
||||
"Email", "App", "Endpoint", "Export Directory"
|
||||
);
|
||||
println!("{}", "-".repeat(110));
|
||||
|
||||
for account in accounts {
|
||||
// Shorten endpoint display for better readability
|
||||
let endpoint_display = if account.endpoint == "https://api.ente.io" {
|
||||
"api.ente.io (prod)".to_string()
|
||||
} else if account.endpoint.starts_with("http://localhost") {
|
||||
format!(
|
||||
"localhost:{}",
|
||||
account.endpoint.split(':').next_back().unwrap_or("")
|
||||
)
|
||||
} else {
|
||||
account.endpoint.clone()
|
||||
};
|
||||
|
||||
println!(
|
||||
"{:<30} {:<10} {:<30} {:<40}",
|
||||
account.email,
|
||||
format!("{:?}", account.app).to_lowercase(),
|
||||
endpoint_display,
|
||||
account.export_dir.as_deref().unwrap_or("Not configured")
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add_account(
|
||||
storage: &Storage,
|
||||
email_arg: Option<String>,
|
||||
password_arg: Option<String>,
|
||||
app_arg: String,
|
||||
endpoint: String,
|
||||
export_dir_arg: Option<String>,
|
||||
) -> Result<()> {
|
||||
println!("\n=== Add Ente Account ===\n");
|
||||
|
||||
// Get email (from arg or prompt)
|
||||
let email = if let Some(email) = email_arg {
|
||||
email
|
||||
} else {
|
||||
Input::new()
|
||||
.with_prompt("Enter your email address")
|
||||
.interact_text()
|
||||
.map_err(|e| crate::models::error::Error::InvalidInput(e.to_string()))?
|
||||
};
|
||||
|
||||
// Parse app type
|
||||
let app = match app_arg.to_lowercase().as_str() {
|
||||
"photos" => App::Photos,
|
||||
"locker" => App::Locker,
|
||||
"auth" => App::Auth,
|
||||
_ => {
|
||||
// If invalid app provided via CLI, use interactive selection
|
||||
if password_arg.is_some() {
|
||||
return Err(crate::models::error::Error::InvalidInput(format!(
|
||||
"Invalid app: {app_arg}. Must be one of: photos, locker, auth"
|
||||
)));
|
||||
}
|
||||
let apps = vec!["photos", "locker", "auth"];
|
||||
let app_index = Select::new()
|
||||
.with_prompt("Select the Ente app")
|
||||
.items(&apps)
|
||||
.default(0)
|
||||
.interact()
|
||||
.map_err(|e| crate::models::error::Error::InvalidInput(e.to_string()))?;
|
||||
match apps[app_index] {
|
||||
"photos" => App::Photos,
|
||||
"locker" => App::Locker,
|
||||
"auth" => App::Auth,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Check if account already exists
|
||||
if let Ok(Some(_existing)) = storage.accounts().get(&email, app) {
|
||||
println!("\n❌ Account already exists for {email} with app {app:?}");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check if we're in non-interactive mode (password provided via CLI)
|
||||
let is_non_interactive = password_arg.is_some();
|
||||
|
||||
// Get password (from arg or prompt)
|
||||
let password = if let Some(password) = password_arg {
|
||||
password
|
||||
} else {
|
||||
Password::new()
|
||||
.with_prompt("Enter your password")
|
||||
.interact()
|
||||
.map_err(|e| crate::models::error::Error::InvalidInput(e.to_string()))?
|
||||
};
|
||||
|
||||
// Get export directory (from arg or use default)
|
||||
let export_dir = if let Some(dir) = export_dir_arg {
|
||||
dir
|
||||
} else if is_non_interactive {
|
||||
// If password was provided via CLI (non-interactive mode), use default path
|
||||
format!("./exports/{email}")
|
||||
} else {
|
||||
Input::new()
|
||||
.with_prompt("Enter export directory path")
|
||||
.default(format!("./exports/{email}"))
|
||||
.interact_text()
|
||||
.map_err(|e| crate::models::error::Error::InvalidInput(e.to_string()))?
|
||||
};
|
||||
|
||||
// Validate export directory
|
||||
let export_path = PathBuf::from(&export_dir);
|
||||
if !export_path.exists() {
|
||||
println!("Creating export directory: {export_dir}");
|
||||
std::fs::create_dir_all(&export_path).map_err(crate::models::error::Error::Io)?;
|
||||
}
|
||||
|
||||
// Initialize API client with the specified endpoint
|
||||
log::info!("Using API endpoint: {endpoint}");
|
||||
let api_client = ApiClient::new(Some(endpoint.clone()))?;
|
||||
let auth_client = AuthClient::new(&api_client);
|
||||
|
||||
println!("\nAuthenticating with Ente servers...");
|
||||
|
||||
// Perform SRP authentication
|
||||
let (auth_response, key_enc_key) = match auth_client.login_with_srp(&email, &password).await {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
println!("\n❌ Authentication failed: {e}");
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle 2FA if required
|
||||
let auth_response = if auth_response.is_mfa_required() {
|
||||
println!("\n📱 Two-factor authentication required");
|
||||
let totp_code: String = Input::new()
|
||||
.with_prompt("Enter TOTP code")
|
||||
.validate_with(|input: &String| {
|
||||
if input.len() == 6 && input.chars().all(char::is_numeric) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("TOTP code must be 6 digits")
|
||||
}
|
||||
})
|
||||
.interact_text()
|
||||
.map_err(|e| crate::models::error::Error::InvalidInput(e.to_string()))?;
|
||||
|
||||
auth_client
|
||||
.verify_totp(
|
||||
auth_response
|
||||
.two_factor_session_id
|
||||
.as_ref()
|
||||
.ok_or_else(|| {
|
||||
crate::models::error::Error::AuthenticationFailed(
|
||||
"No 2FA session ID".to_string(),
|
||||
)
|
||||
})?,
|
||||
&totp_code,
|
||||
)
|
||||
.await?
|
||||
} else if auth_response.is_passkey_required() {
|
||||
println!("\n🔑 Passkey verification required");
|
||||
println!("Please complete passkey verification in your browser...");
|
||||
// TODO: Implement passkey verification flow
|
||||
return Err(crate::models::error::Error::Generic(
|
||||
"Passkey verification not yet implemented".to_string(),
|
||||
));
|
||||
} else {
|
||||
auth_response
|
||||
};
|
||||
|
||||
// Decrypt keys
|
||||
let key_attributes = auth_response.key_attributes.as_ref().ok_or_else(|| {
|
||||
crate::models::error::Error::AuthenticationFailed("No key attributes".to_string())
|
||||
})?;
|
||||
|
||||
println!("\nDecrypting account keys...");
|
||||
|
||||
// Decrypt master key
|
||||
let master_key = secret_box_open(
|
||||
&decode_base64(&key_attributes.encrypted_key)?,
|
||||
&decode_base64(&key_attributes.key_decryption_nonce)?,
|
||||
&key_enc_key,
|
||||
)?;
|
||||
log::info!("Master key decrypted, length: {}", master_key.len());
|
||||
|
||||
// Decrypt secret key
|
||||
let secret_key = secret_box_open(
|
||||
&decode_base64(&key_attributes.encrypted_secret_key)?,
|
||||
&decode_base64(&key_attributes.secret_key_decryption_nonce)?,
|
||||
&master_key,
|
||||
)?;
|
||||
log::info!("Secret key decrypted, length: {}", secret_key.len());
|
||||
|
||||
// Get public key
|
||||
let public_key = decode_base64(&key_attributes.public_key)?;
|
||||
|
||||
// Decrypt token if encrypted
|
||||
let token = if let Some(encrypted_token) = &auth_response.encrypted_token {
|
||||
let encrypted_bytes = decode_base64(encrypted_token)?;
|
||||
log::debug!("Encrypted token bytes length: {}", encrypted_bytes.len());
|
||||
|
||||
let decrypted = sealed_box_open(&encrypted_bytes, &public_key, &secret_key)?;
|
||||
log::debug!("Decrypted token bytes length: {}", decrypted.len());
|
||||
|
||||
// Try to interpret as UTF-8 string first
|
||||
match String::from_utf8(decrypted.clone()) {
|
||||
Ok(token_str) => {
|
||||
log::debug!("Decrypted token is valid UTF-8");
|
||||
// If it's a string, use it as bytes
|
||||
token_str.into_bytes()
|
||||
}
|
||||
Err(_) => {
|
||||
log::debug!("Token is not UTF-8, using raw bytes");
|
||||
// If not UTF-8, use raw bytes
|
||||
decrypted
|
||||
}
|
||||
}
|
||||
} else if let Some(plain_token) = &auth_response.token {
|
||||
plain_token.as_bytes().to_vec()
|
||||
} else {
|
||||
return Err(crate::models::error::Error::AuthenticationFailed(
|
||||
"No token in response".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
// Create account
|
||||
let account = Account {
|
||||
user_id: auth_response.id,
|
||||
email: email.clone(),
|
||||
app,
|
||||
endpoint: endpoint.clone(),
|
||||
export_dir: Some(export_dir.clone()),
|
||||
};
|
||||
|
||||
// Create account secrets
|
||||
let secrets = AccountSecrets {
|
||||
token,
|
||||
master_key,
|
||||
secret_key,
|
||||
public_key,
|
||||
};
|
||||
|
||||
// Store account in database
|
||||
storage.accounts().add(&account)?;
|
||||
storage
|
||||
.accounts()
|
||||
.store_secrets(account.user_id, account.app, &secrets)?;
|
||||
|
||||
println!("\n✅ Account added successfully!");
|
||||
println!(" Email: {email}");
|
||||
println!(" App: {app:?}");
|
||||
println!(" Endpoint: {endpoint}");
|
||||
println!(" Export directory: {export_dir}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_account(storage: &Storage, email: &str, dir: &str, app_str: &str) -> Result<()> {
|
||||
let app = match app_str.to_lowercase().as_str() {
|
||||
"photos" => App::Photos,
|
||||
"locker" => App::Locker,
|
||||
"auth" => App::Auth,
|
||||
_ => {
|
||||
return Err(crate::models::error::Error::InvalidInput(format!(
|
||||
"Invalid app: {app_str}. Must be one of: photos, locker, auth"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
// Check if account exists
|
||||
if storage.accounts().get(email, app)?.is_none() {
|
||||
return Err(crate::models::error::Error::NotFound(format!(
|
||||
"Account not found: {} (app: {:?})",
|
||||
email, app
|
||||
)));
|
||||
}
|
||||
|
||||
// Validate export directory
|
||||
let export_path = PathBuf::from(dir);
|
||||
if !export_path.exists() {
|
||||
println!("Creating export directory: {dir}");
|
||||
std::fs::create_dir_all(&export_path).map_err(crate::models::error::Error::Io)?;
|
||||
}
|
||||
|
||||
// Update account
|
||||
storage.accounts().update_export_dir(email, app, dir)?;
|
||||
|
||||
println!("\n✅ Account updated successfully!");
|
||||
println!(" Email: {email}");
|
||||
println!(" App: {app:?}");
|
||||
println!(" New export directory: {dir}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_token(storage: &Storage, email: &str, app_str: &str) -> Result<()> {
|
||||
let app = match app_str.to_lowercase().as_str() {
|
||||
"photos" => App::Photos,
|
||||
"locker" => App::Locker,
|
||||
"auth" => App::Auth,
|
||||
_ => {
|
||||
return Err(crate::models::error::Error::InvalidInput(format!(
|
||||
"Invalid app: {app_str}. Must be one of: photos, locker, auth"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
// Get account
|
||||
let account = storage.accounts().get(email, app)?.ok_or_else(|| {
|
||||
crate::models::error::Error::NotFound(format!("Account not found: {email}"))
|
||||
})?;
|
||||
|
||||
// Get account secrets
|
||||
let secrets = storage
|
||||
.accounts()
|
||||
.get_secrets(account.user_id, account.app)?
|
||||
.ok_or_else(|| {
|
||||
crate::models::error::Error::NotFound(format!("Secrets not found for account {email}"))
|
||||
})?;
|
||||
|
||||
// Token is stored as raw bytes from sealed_box_open
|
||||
// The Go CLI returns it as base64 URL-encoded string WITH padding (matching TokenStr() in Go)
|
||||
let token_str = base64::engine::general_purpose::URL_SAFE.encode(&secrets.token);
|
||||
|
||||
println!("{token_str}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
1238
rust/src/commands/export.rs
Normal file
1238
rust/src/commands/export.rs
Normal file
File diff suppressed because it is too large
Load Diff
4
rust/src/commands/mod.rs
Normal file
4
rust/src/commands/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod account;
|
||||
pub mod export;
|
||||
|
||||
pub use account::handle_account_command;
|
||||
476
rust/src/commands/sync.rs
Normal file
476
rust/src/commands/sync.rs
Normal file
@@ -0,0 +1,476 @@
|
||||
use crate::Result;
|
||||
use crate::api::client::ApiClient;
|
||||
use crate::api::methods::ApiMethods;
|
||||
use crate::crypto::secret_box_open;
|
||||
use crate::models::{account::Account, metadata::FileMetadata};
|
||||
use crate::storage::Storage;
|
||||
use crate::sync::{SyncEngine, SyncStats, download::DownloadManager};
|
||||
use base64::Engine;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub async fn run_sync(
|
||||
account_email: Option<String>,
|
||||
metadata_only: bool,
|
||||
full_sync: bool,
|
||||
) -> Result<()> {
|
||||
// Initialize crypto
|
||||
crate::crypto::init()?;
|
||||
|
||||
// Open database
|
||||
let config_dir = crate::utils::get_cli_config_dir()?;
|
||||
let db_path = config_dir.join("ente.db");
|
||||
let storage = Storage::new(&db_path)?;
|
||||
|
||||
// Get accounts to sync
|
||||
let accounts = if let Some(email) = account_email {
|
||||
// Sync specific account
|
||||
let all_accounts = storage.accounts().list()?;
|
||||
let matching: Vec<Account> = all_accounts
|
||||
.into_iter()
|
||||
.filter(|a| a.email == email)
|
||||
.collect();
|
||||
|
||||
if matching.is_empty() {
|
||||
return Err(crate::Error::NotFound(format!(
|
||||
"Account not found: {email}"
|
||||
)));
|
||||
}
|
||||
matching
|
||||
} else {
|
||||
// Sync all accounts
|
||||
storage.accounts().list()?
|
||||
};
|
||||
|
||||
if accounts.is_empty() {
|
||||
println!("No accounts configured. Use 'ente-rs account add' first.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Sync each account
|
||||
for account in accounts {
|
||||
println!("\n=== Syncing account: {} ===", account.email);
|
||||
|
||||
if let Err(e) = sync_account(&storage, &account, metadata_only, full_sync).await {
|
||||
log::error!("Failed to sync account {}: {}", account.email, e);
|
||||
println!("❌ Sync failed: {e}");
|
||||
} else {
|
||||
println!("✅ Sync completed successfully!");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn sync_account(
|
||||
storage: &Storage,
|
||||
account: &Account,
|
||||
metadata_only: bool,
|
||||
full_sync: bool,
|
||||
) -> Result<()> {
|
||||
// Get stored secrets
|
||||
let secrets = storage
|
||||
.accounts()
|
||||
.get_secrets(account.user_id, account.app)?
|
||||
.ok_or_else(|| crate::Error::NotFound("Account secrets not found".into()))?;
|
||||
|
||||
// Create API client with account's endpoint
|
||||
let api_client = ApiClient::new(Some(account.endpoint.clone()))?;
|
||||
|
||||
// Store token for this account
|
||||
let token = base64::engine::general_purpose::URL_SAFE.encode(&secrets.token);
|
||||
api_client.add_token(&account.email, &token);
|
||||
|
||||
// Clear sync state if full sync requested
|
||||
if full_sync {
|
||||
println!("Performing full sync (clearing existing sync state)...");
|
||||
storage.sync().clear_sync_state(account.user_id)?;
|
||||
}
|
||||
|
||||
// Create sync engine (need to create new instances for ownership)
|
||||
let db_path = storage
|
||||
.db_path()
|
||||
.ok_or_else(|| crate::Error::Generic("Database path not available".into()))?;
|
||||
|
||||
// Create API client for sync engine
|
||||
let sync_api_client = ApiClient::new(Some(account.endpoint.clone()))?;
|
||||
sync_api_client.add_token(&account.email, &token);
|
||||
|
||||
let sync_storage = Storage::new(db_path)?;
|
||||
let sync_engine = SyncEngine::new(sync_api_client, sync_storage, account.clone());
|
||||
|
||||
// Run sync
|
||||
println!("Fetching collections and files...");
|
||||
let stats = sync_engine.sync().await?;
|
||||
|
||||
// Display sync statistics
|
||||
display_sync_stats(&stats);
|
||||
|
||||
// Download files if not metadata-only
|
||||
if !metadata_only {
|
||||
// Get pending downloads
|
||||
let pending_files = storage.sync().get_pending_downloads(account.user_id)?;
|
||||
|
||||
if !pending_files.is_empty() {
|
||||
println!("\n📥 Found {} files to download", pending_files.len());
|
||||
|
||||
// Get collections to decrypt collection keys
|
||||
// Need to fetch from API to get the api::models::Collection type with encrypted_key
|
||||
let api = ApiMethods::new(&api_client);
|
||||
let api_collections = api.get_collections(&account.email, 0).await?;
|
||||
|
||||
// Decrypt collection keys
|
||||
let collection_keys = decrypt_collection_keys(
|
||||
&api_collections,
|
||||
&secrets.master_key,
|
||||
&secrets.secret_key,
|
||||
)?;
|
||||
|
||||
// Create download manager
|
||||
// Create a new API client for the download manager
|
||||
let download_api_client = ApiClient::new(Some(account.endpoint.clone()))?;
|
||||
download_api_client.add_token(&account.email, &token);
|
||||
|
||||
let mut download_manager = DownloadManager::new(download_api_client)?;
|
||||
download_manager.set_collection_keys(collection_keys);
|
||||
|
||||
// Determine export directory
|
||||
let export_dir = if let Some(ref dir) = account.export_dir {
|
||||
PathBuf::from(dir)
|
||||
} else {
|
||||
std::env::current_dir()?.join("ente-export")
|
||||
};
|
||||
|
||||
// Prepare download tasks with proper paths
|
||||
let download_tasks = prepare_download_tasks(
|
||||
&pending_files,
|
||||
&export_dir,
|
||||
&api_collections,
|
||||
&download_manager,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// First, mark files that already exist as synced
|
||||
let mut already_synced = 0;
|
||||
let mut to_download = Vec::new();
|
||||
|
||||
for (file, path) in download_tasks {
|
||||
if path.exists() {
|
||||
// File already exists, mark it as synced in database
|
||||
storage
|
||||
.sync()
|
||||
.mark_file_synced(file.id, Some(path.to_str().unwrap_or("")))?;
|
||||
already_synced += 1;
|
||||
} else {
|
||||
to_download.push((file, path));
|
||||
}
|
||||
}
|
||||
|
||||
if already_synced > 0 {
|
||||
log::info!("Marked {already_synced} already existing files as synced");
|
||||
}
|
||||
|
||||
if !to_download.is_empty() {
|
||||
println!("📥 Downloading {} new files", to_download.len());
|
||||
|
||||
// Download files with progress bar
|
||||
let download_stats = download_manager
|
||||
.download_files(&account.email, to_download)
|
||||
.await?;
|
||||
|
||||
// Update local paths in database for newly downloaded files
|
||||
for (file, path) in &download_stats.successful_downloads {
|
||||
storage
|
||||
.sync()
|
||||
.mark_file_synced(file.id, Some(path.to_str().unwrap_or("")))?;
|
||||
}
|
||||
|
||||
println!(
|
||||
"\n✅ Downloaded {} files successfully",
|
||||
download_stats.successful
|
||||
);
|
||||
if download_stats.failed > 0 {
|
||||
println!("❌ Failed to download {} files", download_stats.failed);
|
||||
}
|
||||
} else {
|
||||
println!("\n✨ All files are already downloaded");
|
||||
}
|
||||
} else {
|
||||
println!("\n✨ All files are already downloaded");
|
||||
}
|
||||
} else {
|
||||
println!("\n📋 Metadata-only sync completed (skipping file downloads)");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn display_sync_stats(stats: &SyncStats) {
|
||||
println!("\n📊 Sync Statistics:");
|
||||
println!("┌─────────────────────────────────────┐");
|
||||
println!("│ Collections: │");
|
||||
println!(
|
||||
"│ Total: {:5} │",
|
||||
stats.collections.total
|
||||
);
|
||||
println!(
|
||||
"│ New: {:5} │",
|
||||
stats.collections.new
|
||||
);
|
||||
println!(
|
||||
"│ Updated: {:5} │",
|
||||
stats.collections.updated
|
||||
);
|
||||
println!("├─────────────────────────────────────┤");
|
||||
println!("│ Files: │");
|
||||
println!("│ Total: {:5} │", stats.files.total);
|
||||
println!("│ New: {:5} │", stats.files.new);
|
||||
println!(
|
||||
"│ Updated: {:5} │",
|
||||
stats.files.updated
|
||||
);
|
||||
println!("└─────────────────────────────────────┘");
|
||||
}
|
||||
|
||||
/// Decrypt collection keys for file decryption
|
||||
fn decrypt_collection_keys(
|
||||
collections: &[crate::api::models::Collection],
|
||||
master_key: &[u8],
|
||||
_secret_key: &[u8],
|
||||
) -> Result<HashMap<i64, Vec<u8>>> {
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
|
||||
let mut keys = HashMap::new();
|
||||
|
||||
for collection in collections {
|
||||
if collection.is_deleted {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Decrypt collection key
|
||||
let encrypted_bytes = BASE64.decode(&collection.encrypted_key)?;
|
||||
let nonce_bytes = BASE64.decode(&collection.key_decryption_nonce)?;
|
||||
|
||||
match secret_box_open(&encrypted_bytes, &nonce_bytes, master_key) {
|
||||
Ok(key) => {
|
||||
keys.insert(collection.id, key);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to decrypt key for collection {}: {}",
|
||||
collection.id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
/// Sanitize a filename for the filesystem
|
||||
fn sanitize_filename(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| match c {
|
||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||
'\0' => '_',
|
||||
c if c.is_control() => '_',
|
||||
c => c,
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Prepare download tasks with proper file paths
|
||||
async fn prepare_download_tasks(
|
||||
files: &[crate::models::file::RemoteFile],
|
||||
export_dir: &Path,
|
||||
collections: &[crate::api::models::Collection],
|
||||
download_manager: &DownloadManager,
|
||||
) -> Result<Vec<(crate::models::file::RemoteFile, PathBuf)>> {
|
||||
use crate::crypto::decrypt_stream;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use chrono::{TimeZone, Utc};
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
let mut seen_hashes: HashMap<String, PathBuf> = HashMap::new();
|
||||
|
||||
// Create collection lookup map
|
||||
let collection_map: HashMap<i64, &crate::api::models::Collection> =
|
||||
collections.iter().map(|c| (c.id, c)).collect();
|
||||
|
||||
for file in files {
|
||||
// Get collection for this file
|
||||
let collection = collection_map.get(&file.collection_id);
|
||||
|
||||
// Try to decrypt metadata to get original filename
|
||||
let (metadata, pub_magic_metadata) = if let Some(col_key) =
|
||||
download_manager.collection_keys.get(&file.collection_id)
|
||||
{
|
||||
// Decrypt file key first
|
||||
let file_key = {
|
||||
let key_bytes = BASE64.decode(&file.encrypted_key)?;
|
||||
let nonce = BASE64.decode(&file.key_decryption_nonce)?;
|
||||
secret_box_open(&key_bytes, &nonce, col_key)?
|
||||
};
|
||||
|
||||
// Decrypt regular metadata
|
||||
let regular_meta = if !file.metadata.encrypted_data.is_empty() {
|
||||
if !file.metadata.decryption_header.is_empty() {
|
||||
let encrypted_bytes = BASE64.decode(&file.metadata.encrypted_data)?;
|
||||
let header_bytes = BASE64.decode(&file.metadata.decryption_header)?;
|
||||
|
||||
match decrypt_stream(&encrypted_bytes, &header_bytes, &file_key) {
|
||||
Ok(decrypted) => serde_json::from_slice::<FileMetadata>(&decrypted).ok(),
|
||||
Err(e) => {
|
||||
log::warn!("Failed to decrypt metadata for file {}: {}", file.id, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Decrypt public magic metadata if available
|
||||
let pub_meta = if let Some(ref magic) = file.pub_magic_metadata {
|
||||
if !magic.data.is_empty() && !magic.header.is_empty() {
|
||||
let encrypted_bytes = BASE64.decode(&magic.data)?;
|
||||
let header_bytes = BASE64.decode(&magic.header)?;
|
||||
|
||||
match decrypt_stream(&encrypted_bytes, &header_bytes, &file_key) {
|
||||
Ok(decrypted) => {
|
||||
serde_json::from_slice::<serde_json::Value>(&decrypted).ok()
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!(
|
||||
"Failed to decrypt public magic metadata for file {}: {}",
|
||||
file.id,
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
(regular_meta, pub_meta)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
// Generate export path
|
||||
let mut path = export_dir.to_path_buf();
|
||||
|
||||
// Add date-based directory structure
|
||||
let datetime = Utc
|
||||
.timestamp_micros(file.updated_at)
|
||||
.single()
|
||||
.ok_or_else(|| crate::Error::Generic("Invalid timestamp".into()))?;
|
||||
|
||||
let year = datetime.format("%Y").to_string();
|
||||
let month = datetime.format("%m-%B").to_string();
|
||||
|
||||
path.push(year);
|
||||
path.push(month);
|
||||
|
||||
// Add collection name if available
|
||||
if let Some(col) = collection
|
||||
&& let Some(ref name) = col.name
|
||||
&& !name.is_empty()
|
||||
&& name != "Uncategorized"
|
||||
{
|
||||
let safe_name: String = name
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||
c if c.is_control() => '_',
|
||||
c => c,
|
||||
})
|
||||
.collect();
|
||||
path.push(safe_name.trim());
|
||||
}
|
||||
|
||||
// Use filename from public magic metadata (edited name) or regular metadata
|
||||
let filename = {
|
||||
// First check for edited name in public magic metadata
|
||||
let base_name = if let Some(ref pub_meta) = pub_magic_metadata
|
||||
&& let Some(edited_name) = pub_meta.get("editedName")
|
||||
&& let Some(name_str) = edited_name.as_str()
|
||||
&& !name_str.is_empty()
|
||||
{
|
||||
sanitize_filename(name_str)
|
||||
} else if let Some(ref meta) = metadata {
|
||||
// Fall back to original title from metadata
|
||||
if let Some(title) = meta.get_title() {
|
||||
sanitize_filename(title)
|
||||
} else {
|
||||
// Match Go CLI behavior: error if no title found
|
||||
log::error!("File {} has no title in metadata", file.id);
|
||||
continue; // Skip this file
|
||||
}
|
||||
} else {
|
||||
// Match Go CLI behavior: error if no metadata
|
||||
log::error!("File {} has no metadata", file.id);
|
||||
continue; // Skip this file
|
||||
};
|
||||
|
||||
// For live photos, ensure .zip extension if not already present
|
||||
if let Some(ref meta) = metadata {
|
||||
if meta.is_live_photo() && !base_name.to_lowercase().ends_with(".zip") {
|
||||
// Remove any existing extension and add .zip
|
||||
if let Some(pos) = base_name.rfind('.') {
|
||||
format!("{}.zip", &base_name[..pos])
|
||||
} else {
|
||||
format!("{}.zip", base_name)
|
||||
}
|
||||
} else {
|
||||
base_name
|
||||
}
|
||||
} else {
|
||||
base_name
|
||||
}
|
||||
};
|
||||
|
||||
path.push(filename);
|
||||
|
||||
// Check for deduplication by hash
|
||||
let content_hash = if let Some(ref meta) = metadata {
|
||||
match meta.get_file_type() {
|
||||
crate::models::metadata::FileType::Image => {
|
||||
meta.image_hash.as_ref().or(meta.hash.as_ref())
|
||||
}
|
||||
crate::models::metadata::FileType::Video => {
|
||||
meta.video_hash.as_ref().or(meta.hash.as_ref())
|
||||
}
|
||||
_ => meta.hash.as_ref(),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Skip if we've already seen this hash (duplicate)
|
||||
if let Some(hash) = content_hash {
|
||||
if let Some(existing_path) = seen_hashes.get(hash) {
|
||||
log::info!(
|
||||
"Skipping duplicate file {} (same hash as {})",
|
||||
file.id,
|
||||
existing_path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
seen_hashes.insert(hash.clone(), path.clone());
|
||||
}
|
||||
|
||||
tasks.push((file.clone(), path));
|
||||
}
|
||||
|
||||
Ok(tasks)
|
||||
}
|
||||
53
rust/src/crypto/argon.rs
Normal file
53
rust/src/crypto/argon.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use crate::{Error, Result};
|
||||
use libsodium_sys as sodium;
|
||||
|
||||
/// Derive a key using Argon2id algorithm
|
||||
/// This matches the Go implementation using libsodium
|
||||
pub fn derive_argon_key(
|
||||
password: &str,
|
||||
salt: &str,
|
||||
mem_limit: u32,
|
||||
ops_limit: u32,
|
||||
) -> Result<Vec<u8>> {
|
||||
if mem_limit < 1024 || ops_limit < 1 {
|
||||
return Err(Error::InvalidInput(
|
||||
"Invalid memory or operation limits".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Decode salt from base64
|
||||
let salt_bytes = super::decode_base64(salt)?;
|
||||
|
||||
// libsodium requires salt to be exactly crypto_pwhash_SALTBYTES
|
||||
if salt_bytes.len() != sodium::crypto_pwhash_SALTBYTES as usize {
|
||||
return Err(Error::Crypto(format!(
|
||||
"Invalid salt length: expected {}, got {}",
|
||||
sodium::crypto_pwhash_SALTBYTES,
|
||||
salt_bytes.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let mut key = vec![0u8; sodium::crypto_secretbox_KEYBYTES as usize]; // 32 bytes output
|
||||
|
||||
// Convert password to bytes (matching JS sodium.from_string)
|
||||
let password_bytes = password.as_bytes();
|
||||
|
||||
let result = unsafe {
|
||||
sodium::crypto_pwhash(
|
||||
key.as_mut_ptr(),
|
||||
key.len() as u64,
|
||||
password_bytes.as_ptr() as *const std::ffi::c_char,
|
||||
password_bytes.len() as u64,
|
||||
salt_bytes.as_ptr(),
|
||||
ops_limit as u64,
|
||||
mem_limit as usize, // API sends bytes, libsodium-sys expects bytes
|
||||
sodium::crypto_pwhash_ALG_ARGON2ID13 as i32,
|
||||
)
|
||||
};
|
||||
|
||||
if result != 0 {
|
||||
return Err(Error::Crypto("Failed to derive key with Argon2id".into()));
|
||||
}
|
||||
|
||||
Ok(key)
|
||||
}
|
||||
209
rust/src/crypto/chacha.rs
Normal file
209
rust/src/crypto/chacha.rs
Normal file
@@ -0,0 +1,209 @@
|
||||
use crate::{Error, Result};
|
||||
use libsodium_sys as sodium;
|
||||
|
||||
/// Decrypt data encrypted with ChaCha20-Poly1305
|
||||
pub fn decrypt_chacha(ciphertext: &[u8], nonce: &[u8], key: &[u8]) -> Result<Vec<u8>> {
|
||||
if nonce.len() != sodium::crypto_aead_xchacha20poly1305_ietf_NPUBBYTES as usize {
|
||||
return Err(Error::Crypto(format!(
|
||||
"Invalid nonce length: expected {}, got {}",
|
||||
sodium::crypto_aead_xchacha20poly1305_ietf_NPUBBYTES,
|
||||
nonce.len()
|
||||
)));
|
||||
}
|
||||
|
||||
if key.len() != sodium::crypto_aead_xchacha20poly1305_ietf_KEYBYTES as usize {
|
||||
return Err(Error::Crypto(format!(
|
||||
"Invalid key length: expected {}, got {}",
|
||||
sodium::crypto_aead_xchacha20poly1305_ietf_KEYBYTES,
|
||||
key.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let mut plaintext =
|
||||
vec![0u8; ciphertext.len() - sodium::crypto_aead_xchacha20poly1305_ietf_ABYTES as usize];
|
||||
let mut plaintext_len: u64 = 0;
|
||||
|
||||
let result = unsafe {
|
||||
sodium::crypto_aead_xchacha20poly1305_ietf_decrypt(
|
||||
plaintext.as_mut_ptr(),
|
||||
&mut plaintext_len,
|
||||
std::ptr::null_mut(),
|
||||
ciphertext.as_ptr(),
|
||||
ciphertext.len() as u64,
|
||||
std::ptr::null(),
|
||||
0,
|
||||
nonce.as_ptr(),
|
||||
key.as_ptr(),
|
||||
)
|
||||
};
|
||||
|
||||
if result != 0 {
|
||||
return Err(Error::Crypto(
|
||||
"Failed to decrypt with ChaCha20-Poly1305".into(),
|
||||
));
|
||||
}
|
||||
|
||||
plaintext.truncate(plaintext_len as usize);
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
/// Encrypt data with ChaCha20-Poly1305
|
||||
pub fn encrypt_chacha(plaintext: &[u8], nonce: &[u8], key: &[u8]) -> Result<Vec<u8>> {
|
||||
if nonce.len() != sodium::crypto_aead_xchacha20poly1305_ietf_NPUBBYTES as usize {
|
||||
return Err(Error::Crypto(format!(
|
||||
"Invalid nonce length: expected {}, got {}",
|
||||
sodium::crypto_aead_xchacha20poly1305_ietf_NPUBBYTES,
|
||||
nonce.len()
|
||||
)));
|
||||
}
|
||||
|
||||
if key.len() != sodium::crypto_aead_xchacha20poly1305_ietf_KEYBYTES as usize {
|
||||
return Err(Error::Crypto(format!(
|
||||
"Invalid key length: expected {}, got {}",
|
||||
sodium::crypto_aead_xchacha20poly1305_ietf_KEYBYTES,
|
||||
key.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let mut ciphertext =
|
||||
vec![0u8; plaintext.len() + sodium::crypto_aead_xchacha20poly1305_ietf_ABYTES as usize];
|
||||
let mut ciphertext_len: u64 = 0;
|
||||
|
||||
let result = unsafe {
|
||||
sodium::crypto_aead_xchacha20poly1305_ietf_encrypt(
|
||||
ciphertext.as_mut_ptr(),
|
||||
&mut ciphertext_len,
|
||||
plaintext.as_ptr(),
|
||||
plaintext.len() as u64,
|
||||
std::ptr::null(),
|
||||
0,
|
||||
std::ptr::null(),
|
||||
nonce.as_ptr(),
|
||||
key.as_ptr(),
|
||||
)
|
||||
};
|
||||
|
||||
if result != 0 {
|
||||
return Err(Error::Crypto(
|
||||
"Failed to encrypt with ChaCha20-Poly1305".into(),
|
||||
));
|
||||
}
|
||||
|
||||
ciphertext.truncate(ciphertext_len as usize);
|
||||
Ok(ciphertext)
|
||||
}
|
||||
|
||||
/// Open a sealed box (decrypt with public key crypto)
|
||||
pub fn sealed_box_open(ciphertext: &[u8], public_key: &[u8], secret_key: &[u8]) -> Result<Vec<u8>> {
|
||||
if public_key.len() != sodium::crypto_box_PUBLICKEYBYTES as usize {
|
||||
return Err(Error::Crypto(format!(
|
||||
"Invalid public key length: expected {}, got {}",
|
||||
sodium::crypto_box_PUBLICKEYBYTES,
|
||||
public_key.len()
|
||||
)));
|
||||
}
|
||||
|
||||
if secret_key.len() != sodium::crypto_box_SECRETKEYBYTES as usize {
|
||||
return Err(Error::Crypto(format!(
|
||||
"Invalid secret key length: expected {}, got {}",
|
||||
sodium::crypto_box_SECRETKEYBYTES,
|
||||
secret_key.len()
|
||||
)));
|
||||
}
|
||||
|
||||
if ciphertext.len() < sodium::crypto_box_SEALBYTES as usize {
|
||||
return Err(Error::Crypto("Ciphertext too short".into()));
|
||||
}
|
||||
|
||||
let mut plaintext = vec![0u8; ciphertext.len() - sodium::crypto_box_SEALBYTES as usize];
|
||||
|
||||
let result = unsafe {
|
||||
sodium::crypto_box_seal_open(
|
||||
plaintext.as_mut_ptr(),
|
||||
ciphertext.as_ptr(),
|
||||
ciphertext.len() as u64,
|
||||
public_key.as_ptr(),
|
||||
secret_key.as_ptr(),
|
||||
)
|
||||
};
|
||||
|
||||
if result != 0 {
|
||||
return Err(Error::Crypto("Failed to open sealed box".into()));
|
||||
}
|
||||
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
/// Open a secret box (decrypt with XSalsa20-Poly1305)
|
||||
pub fn secret_box_open(ciphertext: &[u8], nonce: &[u8], key: &[u8]) -> Result<Vec<u8>> {
|
||||
if nonce.len() != sodium::crypto_secretbox_NONCEBYTES as usize {
|
||||
return Err(Error::Crypto(format!(
|
||||
"Invalid nonce length: expected {}, got {}",
|
||||
sodium::crypto_secretbox_NONCEBYTES,
|
||||
nonce.len()
|
||||
)));
|
||||
}
|
||||
|
||||
if key.len() != sodium::crypto_secretbox_KEYBYTES as usize {
|
||||
return Err(Error::Crypto(format!(
|
||||
"Invalid key length: expected {}, got {}",
|
||||
sodium::crypto_secretbox_KEYBYTES,
|
||||
key.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let mut plaintext = vec![0u8; ciphertext.len() - sodium::crypto_secretbox_MACBYTES as usize];
|
||||
|
||||
let result = unsafe {
|
||||
sodium::crypto_secretbox_open_easy(
|
||||
plaintext.as_mut_ptr(),
|
||||
ciphertext.as_ptr(),
|
||||
ciphertext.len() as u64,
|
||||
nonce.as_ptr(),
|
||||
key.as_ptr(),
|
||||
)
|
||||
};
|
||||
|
||||
if result != 0 {
|
||||
return Err(Error::Crypto("Failed to open secret box".into()));
|
||||
}
|
||||
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
/// Seal a secret box (encrypt with XSalsa20-Poly1305)
|
||||
pub fn secret_box_seal(plaintext: &[u8], nonce: &[u8], key: &[u8]) -> Result<Vec<u8>> {
|
||||
if nonce.len() != sodium::crypto_secretbox_NONCEBYTES as usize {
|
||||
return Err(Error::Crypto(format!(
|
||||
"Invalid nonce length: expected {}, got {}",
|
||||
sodium::crypto_secretbox_NONCEBYTES,
|
||||
nonce.len()
|
||||
)));
|
||||
}
|
||||
|
||||
if key.len() != sodium::crypto_secretbox_KEYBYTES as usize {
|
||||
return Err(Error::Crypto(format!(
|
||||
"Invalid key length: expected {}, got {}",
|
||||
sodium::crypto_secretbox_KEYBYTES,
|
||||
key.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let mut ciphertext = vec![0u8; plaintext.len() + sodium::crypto_secretbox_MACBYTES as usize];
|
||||
|
||||
let result = unsafe {
|
||||
sodium::crypto_secretbox_easy(
|
||||
ciphertext.as_mut_ptr(),
|
||||
plaintext.as_ptr(),
|
||||
plaintext.len() as u64,
|
||||
nonce.as_ptr(),
|
||||
key.as_ptr(),
|
||||
)
|
||||
};
|
||||
|
||||
if result != 0 {
|
||||
return Err(Error::Crypto("Failed to seal secret box".into()));
|
||||
}
|
||||
|
||||
Ok(ciphertext)
|
||||
}
|
||||
35
rust/src/crypto/kdf.rs
Normal file
35
rust/src/crypto/kdf.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use crate::{Error, Result};
|
||||
use libsodium_sys as sodium;
|
||||
|
||||
const LOGIN_SUB_KEY_LEN: usize = 32;
|
||||
const LOGIN_SUB_KEY_ID: u64 = 1;
|
||||
const LOGIN_SUB_KEY_CONTEXT: &[u8] = b"loginctx";
|
||||
|
||||
/// Derive login key from key encryption key
|
||||
/// This matches the web implementation's deriveSRPLoginSubKey function
|
||||
pub fn derive_login_key(key_enc_key: &[u8]) -> Result<Vec<u8>> {
|
||||
// Derive 32 bytes using crypto_kdf_derive_from_key
|
||||
let mut sub_key = vec![0u8; LOGIN_SUB_KEY_LEN];
|
||||
|
||||
// Ensure context is exactly 8 bytes (crypto_kdf_CONTEXTBYTES)
|
||||
let mut context = [0u8; sodium::crypto_kdf_CONTEXTBYTES as usize];
|
||||
let context_len = LOGIN_SUB_KEY_CONTEXT.len().min(context.len());
|
||||
context[..context_len].copy_from_slice(&LOGIN_SUB_KEY_CONTEXT[..context_len]);
|
||||
|
||||
let result = unsafe {
|
||||
sodium::crypto_kdf_derive_from_key(
|
||||
sub_key.as_mut_ptr(),
|
||||
LOGIN_SUB_KEY_LEN,
|
||||
LOGIN_SUB_KEY_ID,
|
||||
context.as_ptr() as *const std::ffi::c_char,
|
||||
key_enc_key.as_ptr(),
|
||||
)
|
||||
};
|
||||
|
||||
if result != 0 {
|
||||
return Err(Error::Crypto("Failed to derive login subkey".into()));
|
||||
}
|
||||
|
||||
// Return the first 16 bytes of the derived key (matching web implementation)
|
||||
Ok(sub_key[..16].to_vec())
|
||||
}
|
||||
38
rust/src/crypto/mod.rs
Normal file
38
rust/src/crypto/mod.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use crate::Result;
|
||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
||||
use libsodium_sys as sodium;
|
||||
use std::sync::Once;
|
||||
|
||||
mod argon;
|
||||
mod chacha;
|
||||
mod kdf;
|
||||
mod stream;
|
||||
|
||||
pub use argon::derive_argon_key;
|
||||
pub use chacha::{
|
||||
decrypt_chacha, encrypt_chacha, sealed_box_open, secret_box_open, secret_box_seal,
|
||||
};
|
||||
pub use kdf::derive_login_key;
|
||||
pub use stream::{StreamDecryptor, decrypt_file_data, decrypt_stream};
|
||||
|
||||
static INIT: Once = Once::new();
|
||||
|
||||
/// Initialize libsodium. Must be called before any crypto operations.
|
||||
pub fn init() -> Result<()> {
|
||||
INIT.call_once(|| unsafe {
|
||||
if sodium::sodium_init() < 0 {
|
||||
panic!("Failed to initialize libsodium");
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decode base64 string to bytes
|
||||
pub fn decode_base64(input: &str) -> Result<Vec<u8>> {
|
||||
Ok(BASE64.decode(input)?)
|
||||
}
|
||||
|
||||
/// Encode bytes to base64 string
|
||||
pub fn encode_base64(input: &[u8]) -> String {
|
||||
BASE64.encode(input)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user