diff --git a/.github/workflows/mobile-daily-internal.yml b/.github/workflows/mobile-daily-internal.yml index f14a4d3b7e..2763ff5ea7 100644 --- a/.github/workflows/mobile-daily-internal.yml +++ b/.github/workflows/mobile-daily-internal.yml @@ -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: @@ -39,11 +71,6 @@ jobs: flutter-version: ${{ env.FLUTTER_VERSION }} cache: true - - name: Install Rust ${{ env.RUST_VERSION }} - uses: dtolnay/rust-toolchain@master - with: - toolchain: ${{ env.RUST_VERSION }} - - name: Install Flutter Rust Bridge run: cargo install flutter_rust_bridge_codegen diff --git a/.github/workflows/mobile-internal-release.yml b/.github/workflows/mobile-internal-release.yml deleted file mode 100644 index 768609dfa3..0000000000 --- a/.github/workflows/mobile-internal-release.yml +++ /dev/null @@ -1,77 +0,0 @@ -name: "Old Internal release (photos)" - -on: - workflow_dispatch: # Allow manually running the action - -env: - FLUTTER_VERSION: "3.32.8" - RUST_VERSION: "1.85.1" - -permissions: - contents: write - -jobs: - build: - runs-on: ubuntu-latest - - defaults: - run: - working-directory: mobile/apps/photos - - steps: - - name: Checkout code and submodules - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Setup JDK 17 - uses: actions/setup-java@v1 - with: - java-version: 17 - - - name: Install Flutter ${{ env.FLUTTER_VERSION }} - uses: subosito/flutter-action@v2 - with: - channel: "stable" - flutter-version: ${{ env.FLUTTER_VERSION }} - cache: true - - - name: Install Rust ${{ env.RUST_VERSION }} - uses: dtolnay/rust-toolchain@master - with: - toolchain: ${{ env.RUST_VERSION }} - - - name: Install Flutter Rust Bridge - run: cargo install flutter_rust_bridge_codegen - - - name: Setup keys - uses: timheuer/base64-to-file@v1 - with: - fileName: "keystore/ente_photos_key.jks" - encodedString: ${{ secrets.SIGNING_KEY_PHOTOS }} - - - name: Build PlayStore AAB - run: | - flutter build appbundle --dart-define=cronetHttpNoPlay=true --release --flavor playstore - env: - SIGNING_KEY_PATH: "/home/runner/work/_temp/keystore/ente_photos_key.jks" - SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS_PHOTOS }} - SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD_PHOTOS }} - SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD_PHOTOS }} - - - name: Upload AAB to PlayStore - uses: r0adkll/upload-google-play@v1 - with: - serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }} - packageName: io.ente.photos - releaseFiles: mobile/apps/photos/build/app/outputs/bundle/playstoreRelease/app-playstore-release.aab - track: internal - - - name: Notify Discord - uses: sarisia/actions-status-discord@v1 - with: - webhook: ${{ secrets.DISCORD_INTERNAL_RELEASE_WEBHOOK }} - nodetail: true - title: "🏆 Internal release Photos (Branch: ${{ github.ref_name }})" - description: "[Download](https://play.google.com/store/apps/details?id=io.ente.photos)" - color: 0x00ff00 diff --git a/.github/workflows/mobile-release.yml b/.github/workflows/mobile-release.yml index a449327b55..2fdc6b6917 100644 --- a/.github/workflows/mobile-release.yml +++ b/.github/workflows/mobile-release.yml @@ -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: diff --git a/mobile/apps/locker/lib/app.dart b/mobile/apps/locker/lib/app.dart index 05aea2ae6d..006e573542 100644 --- a/mobile/apps/locker/lib/app.dart +++ b/mobile/apps/locker/lib/app.dart @@ -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 @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 return MaterialApp( title: "ente", themeMode: ThemeMode.system, - theme: lightTheme, - darkTheme: darkTheme, + theme: lightThemeData, + darkTheme: darkThemeData, debugShowCheckedModeBanner: false, locale: locale, supportedLocales: appSupportedLocales, diff --git a/mobile/apps/locker/lib/main.dart b/mobile/apps/locker/lib/main.dart index da25ae5c72..7ddc304542 100644 --- a/mobile/apps/locker/lib/main.dart +++ b/mobile/apps/locker/lib/main.dart @@ -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 _runInForeground() async { lockScreen: LockScreen(Configuration.instance), enabled: await LockScreenSettings.instance.shouldShowLockScreen(), locale: locale, + lightTheme: lightThemeData, + darkTheme: darkThemeData, savedThemeMode: savedThemeMode, supportedLocales: appSupportedLocales, localizationsDelegates: const [ diff --git a/mobile/apps/photos/android/app/src/main/AndroidManifest.xml b/mobile/apps/photos/android/app/src/main/AndroidManifest.xml index 4583051e80..d9ccfbbe48 100644 --- a/mobile/apps/photos/android/app/src/main/AndroidManifest.xml +++ b/mobile/apps/photos/android/app/src/main/AndroidManifest.xml @@ -135,6 +135,17 @@ + + + + + + diff --git a/mobile/apps/photos/android/app/src/main/res/drawable-hdpi/ic_ducky_hugging_e_launcher_foreground.png b/mobile/apps/photos/android/app/src/main/res/drawable-hdpi/ic_ducky_hugging_e_launcher_foreground.png new file mode 100644 index 0000000000..73978475d7 Binary files /dev/null and b/mobile/apps/photos/android/app/src/main/res/drawable-hdpi/ic_ducky_hugging_e_launcher_foreground.png differ diff --git a/mobile/apps/photos/android/app/src/main/res/drawable-mdpi/ic_ducky_hugging_e_launcher_foreground.png b/mobile/apps/photos/android/app/src/main/res/drawable-mdpi/ic_ducky_hugging_e_launcher_foreground.png new file mode 100644 index 0000000000..87614de2da Binary files /dev/null and b/mobile/apps/photos/android/app/src/main/res/drawable-mdpi/ic_ducky_hugging_e_launcher_foreground.png differ diff --git a/mobile/apps/photos/android/app/src/main/res/drawable-xhdpi/ic_ducky_hugging_e_launcher_foreground.png b/mobile/apps/photos/android/app/src/main/res/drawable-xhdpi/ic_ducky_hugging_e_launcher_foreground.png new file mode 100644 index 0000000000..be7647dc09 Binary files /dev/null and b/mobile/apps/photos/android/app/src/main/res/drawable-xhdpi/ic_ducky_hugging_e_launcher_foreground.png differ diff --git a/mobile/apps/photos/android/app/src/main/res/drawable-xxhdpi/ic_ducky_hugging_e_launcher_foreground.png b/mobile/apps/photos/android/app/src/main/res/drawable-xxhdpi/ic_ducky_hugging_e_launcher_foreground.png new file mode 100644 index 0000000000..567aa17a88 Binary files /dev/null and b/mobile/apps/photos/android/app/src/main/res/drawable-xxhdpi/ic_ducky_hugging_e_launcher_foreground.png differ diff --git a/mobile/apps/photos/android/app/src/main/res/drawable-xxxhdpi/ic_ducky_hugging_e_launcher_foreground.png b/mobile/apps/photos/android/app/src/main/res/drawable-xxxhdpi/ic_ducky_hugging_e_launcher_foreground.png new file mode 100644 index 0000000000..908f31941b Binary files /dev/null and b/mobile/apps/photos/android/app/src/main/res/drawable-xxxhdpi/ic_ducky_hugging_e_launcher_foreground.png differ diff --git a/mobile/apps/photos/android/app/src/main/res/mipmap-anydpi-v26/icon_ducky_hugging_e.xml b/mobile/apps/photos/android/app/src/main/res/mipmap-anydpi-v26/icon_ducky_hugging_e.xml new file mode 100644 index 0000000000..dbf28a4248 --- /dev/null +++ b/mobile/apps/photos/android/app/src/main/res/mipmap-anydpi-v26/icon_ducky_hugging_e.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mobile/apps/photos/assets/ducky_analyze_files.riv b/mobile/apps/photos/assets/ducky_analyze_files.riv new file mode 100644 index 0000000000..af3501deef Binary files /dev/null and b/mobile/apps/photos/assets/ducky_analyze_files.riv differ diff --git a/mobile/apps/photos/assets/ducky_cleaning_static.svg b/mobile/apps/photos/assets/ducky_cleaning_static.svg new file mode 100644 index 0000000000..5876502d83 --- /dev/null +++ b/mobile/apps/photos/assets/ducky_cleaning_static.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/apps/photos/assets/launcher_icon/icon-ducky-hugging-e-foreground.png b/mobile/apps/photos/assets/launcher_icon/icon-ducky-hugging-e-foreground.png new file mode 100644 index 0000000000..9ea134fe79 Binary files /dev/null and b/mobile/apps/photos/assets/launcher_icon/icon-ducky-hugging-e-foreground.png differ diff --git a/mobile/apps/photos/assets/launcher_icon/icon-ducky-hugging-e.png b/mobile/apps/photos/assets/launcher_icon/icon-ducky-hugging-e.png new file mode 100644 index 0000000000..79c9d175f4 Binary files /dev/null and b/mobile/apps/photos/assets/launcher_icon/icon-ducky-hugging-e.png differ diff --git a/mobile/apps/photos/ios/Runner/Assets.xcassets/IconDuckyHuggingE.appiconset/Contents.json b/mobile/apps/photos/ios/Runner/Assets.xcassets/IconDuckyHuggingE.appiconset/Contents.json new file mode 100644 index 0000000000..e3d18b95e8 --- /dev/null +++ b/mobile/apps/photos/ios/Runner/Assets.xcassets/IconDuckyHuggingE.appiconset/Contents.json @@ -0,0 +1,38 @@ +{ + "images" : [ + { + "filename" : "IconDuckyHuggingEAny.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "IconDuckyHuggingEDark.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "filename" : "IconDuckyHuggingETinted.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} \ No newline at end of file diff --git a/mobile/apps/photos/ios/Runner/Assets.xcassets/IconDuckyHuggingE.appiconset/IconDuckyHuggingEAny.png b/mobile/apps/photos/ios/Runner/Assets.xcassets/IconDuckyHuggingE.appiconset/IconDuckyHuggingEAny.png new file mode 100644 index 0000000000..d0f64c36d6 Binary files /dev/null and b/mobile/apps/photos/ios/Runner/Assets.xcassets/IconDuckyHuggingE.appiconset/IconDuckyHuggingEAny.png differ diff --git a/mobile/apps/photos/ios/Runner/Assets.xcassets/IconDuckyHuggingE.appiconset/IconDuckyHuggingEDark.png b/mobile/apps/photos/ios/Runner/Assets.xcassets/IconDuckyHuggingE.appiconset/IconDuckyHuggingEDark.png new file mode 100644 index 0000000000..0e1867629d Binary files /dev/null and b/mobile/apps/photos/ios/Runner/Assets.xcassets/IconDuckyHuggingE.appiconset/IconDuckyHuggingEDark.png differ diff --git a/mobile/apps/photos/ios/Runner/Assets.xcassets/IconDuckyHuggingE.appiconset/IconDuckyHuggingETinted.png b/mobile/apps/photos/ios/Runner/Assets.xcassets/IconDuckyHuggingE.appiconset/IconDuckyHuggingETinted.png new file mode 100644 index 0000000000..71043295a7 Binary files /dev/null and b/mobile/apps/photos/ios/Runner/Assets.xcassets/IconDuckyHuggingE.appiconset/IconDuckyHuggingETinted.png differ diff --git a/mobile/apps/photos/lib/core/configuration.dart b/mobile/apps/photos/lib/core/configuration.dart index 304a9f931c..9fc6a224b2 100644 --- a/mobile/apps/photos/lib/core/configuration.dart +++ b/mobile/apps/photos/lib/core/configuration.dart @@ -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(); diff --git a/mobile/apps/photos/lib/core/constants.dart b/mobile/apps/photos/lib/core/constants.dart index 272e7d63a9..6215ebc7d7 100644 --- a/mobile/apps/photos/lib/core/constants.dart +++ b/mobile/apps/photos/lib/core/constants.dart @@ -27,7 +27,7 @@ const subGalleryMultiplier = 10; // used to identify which ente file are available in app cache const String sharedMediaIdentifier = 'ente-shared-media://'; -const galleryThumbnailDiskLoadDeferDuration = Duration(milliseconds: 80); +const galleryThumbnailDiskLoadDeferDuration = Duration(milliseconds: 500); const galleryThumbnailServerLoadDeferDuration = Duration(milliseconds: 80); // 256 bit key maps to 24 words diff --git a/mobile/apps/photos/lib/core/exceptions.dart b/mobile/apps/photos/lib/core/exceptions.dart new file mode 100644 index 0000000000..e4bcdee7eb --- /dev/null +++ b/mobile/apps/photos/lib/core/exceptions.dart @@ -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'; +} \ No newline at end of file diff --git a/mobile/apps/photos/lib/db/ml/clip_vector_db.dart b/mobile/apps/photos/lib/db/ml/clip_vector_db.dart index 2d6f4c86f3..29d24b324e 100644 --- a/mobile/apps/photos/lib/db/ml/clip_vector_db.dart +++ b/mobile/apps/photos/lib/db/ml/clip_vector_db.dart @@ -1,3 +1,4 @@ +import "dart:io" show File; import "dart:typed_data" show Float32List; import "package:flutter_rust_bridge/flutter_rust_bridge.dart" show Uint64List; @@ -12,8 +13,8 @@ import "package:shared_preferences/shared_preferences.dart"; class ClipVectorDB { static final Logger _logger = Logger("ClipVectorDB"); - static const _databaseName = "ente.ml.vectordb.clip"; - static const _kMigrationKey = "clip_vector_migration"; + static const _databaseName = "ente.ml.vectordb.clip.usearch"; + static const _kMigrationKey = "clip_vectordb_migration"; static final BigInt _embeddingDimension = BigInt.from(512); @@ -36,11 +37,10 @@ class ClipVectorDB { Future _initVectorDB() async { final documentsDirectory = await getApplicationDocumentsDirectory(); - final String databaseDirectory = - join(documentsDirectory.path, _databaseName); - _logger.info("Opening vectorDB access: DB path " + databaseDirectory); + final String dbPath = join(documentsDirectory.path, _databaseName); + _logger.info("Opening vectorDB access: DB path " + dbPath); final vectorDB = VectorDb( - filePath: databaseDirectory, + filePath: dbPath, dimensions: _embeddingDimension, ); final stats = await getIndexStats(vectorDB); @@ -141,17 +141,6 @@ class ClipVectorDB { } } - Future deleteIndex() async { - final db = await _vectorDB; - try { - await db.deleteIndex(); - _vectorDbFuture = null; - } catch (e, s) { - _logger.severe("Error deleting index", e, s); - rethrow; - } - } - Future getIndexStats([VectorDb? db]) async { db ??= await _vectorDB; try { @@ -278,6 +267,40 @@ class ClipVectorDB { rethrow; } } + + Future deleteIndex() async { + final db = await _vectorDB; + try { + await db.deleteIndex(); + _vectorDbFuture = null; + } catch (e, s) { + _logger.severe("Error deleting index", e, s); + rethrow; + } + } + + Future deleteIndexFile({bool undoMigration = false}) async { + try { + final documentsDirectory = await getApplicationDocumentsDirectory(); + 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; + } + } } class VectorDbStats { diff --git a/mobile/apps/photos/lib/db/ml/db.dart b/mobile/apps/photos/lib/db/ml/db.dart index c29e004706..5ad34e77fd 100644 --- a/mobile/apps/photos/lib/db/ml/db.dart +++ b/mobile/apps/photos/lib/db/ml/db.dart @@ -260,6 +260,9 @@ class MLDataDB with SqlDbBase implements IMLDataDB { await db.execute(deleteNotPersonFeedbackTable); await db.execute(deleteClipEmbeddingsTable); await db.execute(deleteFileDataTable); + if (await ClipVectorDB.instance.checkIfMigrationDone()) { + await ClipVectorDB.instance.deleteIndexFile(); + } } @override @@ -1289,8 +1292,11 @@ class MLDataDB with SqlDbBase implements IMLDataDB { 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"); @@ -1320,6 +1326,9 @@ class MLDataDB with SqlDbBase implements IMLDataDB { embeddings.add(Float32List.view(result[embeddingColumn].buffer)); } else { weirdCount++; + _logger.warning( + "Weird clip embedding length ${embedding.length} for fileID ${result[fileIDColumn]}, skipping", + ); } } _logger.info( @@ -1346,7 +1355,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB { "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", @@ -1357,6 +1366,8 @@ class MLDataDB with SqlDbBase implements IMLDataDB { rethrow; } finally { stopwatch.stop(); + // Make sure compute can run again + computeController.unblockCompute(blocker: migrationKey); } } diff --git a/mobile/apps/photos/lib/db/upload_locks_db.dart b/mobile/apps/photos/lib/db/upload_locks_db.dart index 24c0e397c3..b01a06584a 100644 --- a/mobile/apps/photos/lib/db/upload_locks_db.dart +++ b/mobile/apps/photos/lib/db/upload_locks_db.dart @@ -66,7 +66,6 @@ class UploadLocksDB { static final migrationScripts = [ ..._createTrackUploadsTable(), - ..._createStreamQueueTable(), ]; final dbConfig = MigrationConfig( @@ -142,11 +141,6 @@ class UploadLocksDB { ${_streamUploadErrorTable.columnCreatedAt} INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL ) ''', - ]; - } - - static List _createStreamQueueTable() { - return [ ''' CREATE TABLE IF NOT EXISTS ${_streamQueueTable.table} ( ${_streamQueueTable.columnUploadedFileID} INTEGER PRIMARY KEY, @@ -158,7 +152,7 @@ class UploadLocksDB { } Future clearTable() async { - final db = await instance.database; + final db = await database; await db.delete(_uploadLocksTable.table); await db.delete(_trackUploadTable.table); await db.delete(_partsTable.table); @@ -166,7 +160,7 @@ class UploadLocksDB { } Future acquireLock(String id, String owner, int time) async { - final db = await instance.database; + final db = await database; final row = {}; row[_uploadLocksTable.columnID] = id; row[_uploadLocksTable.columnOwner] = owner; @@ -179,7 +173,7 @@ class UploadLocksDB { } Future getLockData(String id) async { - final db = await instance.database; + final db = await database; final rows = await db.query( _uploadLocksTable.table, where: '${_uploadLocksTable.columnID} = ?', @@ -196,7 +190,7 @@ class UploadLocksDB { } Future isLocked(String id, String owner) async { - final db = await instance.database; + final db = await database; final rows = await db.query( _uploadLocksTable.table, where: @@ -207,7 +201,7 @@ class UploadLocksDB { } Future releaseLock(String id, String owner) async { - final db = await instance.database; + final db = await database; return db.delete( _uploadLocksTable.table, where: @@ -217,7 +211,7 @@ class UploadLocksDB { } Future releaseLocksAcquiredByOwnerBefore(String owner, int time) async { - final db = await instance.database; + final db = await database; return db.delete( _uploadLocksTable.table, where: @@ -227,7 +221,7 @@ class UploadLocksDB { } Future releaseAllLocksAcquiredBefore(int time) async { - final db = await instance.database; + final db = await database; return db.delete( _uploadLocksTable.table, where: '${_uploadLocksTable.columnTime} < ?', @@ -241,7 +235,7 @@ class UploadLocksDB { String fileHash, int collectionID, ) async { - final db = await instance.database; + final db = await database; final rows = await db.query( _trackUploadTable.table, @@ -268,7 +262,7 @@ class UploadLocksDB { String fileHash, int collectionID, ) async { - final db = await instance.database; + final db = await database; await db.update( _trackUploadTable.table, { @@ -291,7 +285,7 @@ class UploadLocksDB { String fileHash, int collectionID, ) async { - final db = await instance.database; + final db = await database; final rows = await db.query( _trackUploadTable.table, where: '${_trackUploadTable.columnLocalID} = ?' @@ -349,7 +343,7 @@ class UploadLocksDB { int uploadedFileID, String errorMessage, ) async { - final db = await UploadLocksDB.instance.database; + final db = await database; await db.insert( _streamUploadErrorTable.table, @@ -367,7 +361,7 @@ class UploadLocksDB { int uploadedFileID, String errorMessage, ) async { - final db = await instance.database; + final db = await database; await db.update( _streamUploadErrorTable.table, { @@ -381,7 +375,7 @@ class UploadLocksDB { } Future deleteStreamUploadErrorEntry(int uploadedFileID) async { - final db = await instance.database; + final db = await database; return await db.delete( _streamUploadErrorTable.table, where: '${_streamUploadErrorTable.columnUploadedFileID} = ?', @@ -390,7 +384,7 @@ class UploadLocksDB { } Future> getStreamUploadError() { - return instance.database.then((db) async { + return database.then((db) async { final rows = await db.query( _streamUploadErrorTable.table, columns: [ @@ -419,7 +413,7 @@ class UploadLocksDB { String keyNonce, { required int partSize, }) async { - final db = await UploadLocksDB.instance.database; + final db = await database; final objectKey = urls.objectKey; await db.insert( @@ -462,7 +456,7 @@ class UploadLocksDB { int partNumber, String etag, ) async { - final db = await instance.database; + final db = await database; await db.update( _partsTable.table, { @@ -479,7 +473,7 @@ class UploadLocksDB { String objectKey, MultipartStatus status, ) async { - final db = await instance.database; + final db = await database; await db.update( _trackUploadTable.table, { @@ -493,7 +487,7 @@ class UploadLocksDB { Future deleteMultipartTrack( String localId, ) async { - final db = await instance.database; + final db = await database; return await db.delete( _trackUploadTable.table, where: '${_trackUploadTable.columnLocalID} = ?', @@ -503,7 +497,7 @@ class UploadLocksDB { // getFileNameToLastAttemptedAtMap returns a map of encrypted file name to last attempted at time Future> getFileNameToLastAttemptedAtMap() { - return instance.database.then((db) async { + return database.then((db) async { final rows = await db.query( _trackUploadTable.table, columns: [ @@ -525,7 +519,7 @@ class UploadLocksDB { String fileHash, int collectionID, ) { - return instance.database.then((db) async { + return database.then((db) async { final rows = await db.query( _trackUploadTable.table, where: '${_trackUploadTable.columnLocalID} = ?' @@ -546,7 +540,7 @@ class UploadLocksDB { int uploadedFileID, String queueType, // 'create' or 'recreate' ) async { - final db = await instance.database; + final db = await database; await db.insert( _streamQueueTable.table, { @@ -558,7 +552,7 @@ class UploadLocksDB { } Future removeFromStreamQueue(int uploadedFileID) async { - final db = await instance.database; + final db = await database; await db.delete( _streamQueueTable.table, where: '${_streamQueueTable.columnUploadedFileID} = ?', @@ -567,7 +561,7 @@ class UploadLocksDB { } Future> getStreamQueue() async { - final db = await instance.database; + final db = await database; final rows = await db.query( _streamQueueTable.table, columns: [ @@ -584,7 +578,7 @@ class UploadLocksDB { } Future isInStreamQueue(int uploadedFileID) async { - final db = await instance.database; + final db = await database; final rows = await db.query( _streamQueueTable.table, where: '${_streamQueueTable.columnUploadedFileID} = ?', diff --git a/mobile/apps/photos/lib/l10n/intl_en.arb b/mobile/apps/photos/lib/l10n/intl_en.arb index 825cbe44fb..96e6e2d3cb 100644 --- a/mobile/apps/photos/lib/l10n/intl_en.arb +++ b/mobile/apps/photos/lib/l10n/intl_en.arb @@ -1831,7 +1831,8 @@ "videosProcessed": "Videos processed", "totalVideos": "Total videos", "skippedVideos": "Skipped videos", - "videoStreamingDescription": "Play videos instantly on any device. Enable to process video streams on this device.", + "videoStreamingDescriptionLine1": "Play videos instantly on any device.", + "videoStreamingDescriptionLine2": "Enable to process video streams on this device.", "videoStreamingNote": "Only videos from last 60 days and under 1 minute are processed on this device. For older/longer videos, enable streaming in the desktop app.", "createStream": "Create stream", "recreateStream": "Recreate stream", @@ -1842,14 +1843,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", @@ -1866,7 +1859,7 @@ "@deletePhotosWithSize": { "placeholders": { "count": { - "type": "int" + "type": "String" }, "size": { "type": "String" @@ -1922,12 +1915,9 @@ "deleteFiles": "Delete files", "areYouSureDeleteFiles": "Are you sure you want to delete these files?", "greatJob": "Great job!", - "cleanedUpSimilarImages": "You cleaned up {count, plural, =1{{count} similar image} other{{count} similar images}} and freed up {size}", + "cleanedUpSimilarImages": "You freed up {size} of space", "@cleanedUpSimilarImages": { "placeholders": { - "count": { - "type": "int" - }, "size": { "type": "String" } @@ -1935,11 +1925,19 @@ }, "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": "Use ML to find images that look similar to each other." + "useMLToFindSimilarImages": "Review and remove images that look similar to each other.", + "all": "All", + "similar": "Similar", + "identical": "Identical", + "nothingHereTryAnotherFilter": "Nothing here, try another filter! 👀", + "related": "Related", + "hoorayyyy": "Hoorayyyy!", + "nothingToTidyUpHere": "Nothing to tidy up here", + "deletingDash": "Deleting - " } diff --git a/mobile/apps/photos/lib/services/machine_learning/compute_controller.dart b/mobile/apps/photos/lib/services/machine_learning/compute_controller.dart index 81a0d91b4d..1a76da4bb0 100644 --- a/mobile/apps/photos/lib/services/machine_learning/compute_controller.dart +++ b/mobile/apps/photos/lib/services/machine_learning/compute_controller.dart @@ -10,7 +10,7 @@ import "package:photos/core/event_bus.dart"; import "package:photos/events/compute_control_event.dart"; import "package:thermal/thermal.dart"; -enum _ComputeRunState { +enum ComputeRunState { idle, runningML, generatingStream, @@ -32,10 +32,17 @@ 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 _computeBlocks = {}; + late Timer _userInteractionTimer; - _ComputeRunState _currentRunState = _ComputeRunState.idle; + ComputeRunState _currentRunState = ComputeRunState.idle; bool _waitingToRunML = false; bool get isDeviceHealthy => _isDeviceHealthy; @@ -70,30 +77,49 @@ class ComputeController { _logger.info('init done '); } - bool requestCompute({bool ml = false, bool stream = false}) { - _logger.info("Requesting compute: ml: $ml, stream: $stream"); - if (!_isDeviceHealthy || !_canRunGivenUserInteraction()) { - _logger.info("Device not healthy or user interacting, denying request."); + bool requestCompute({ + bool ml = false, + bool stream = false, + bool bypassInteractionCheck = false, + bool bypassMLWaiting = false, + }) { + _logger.info( + "Requesting compute: ml: $ml, stream: $stream, bypassInteraction: $bypassInteractionCheck, bypassMLWaiting: $bypassMLWaiting", + ); + if (!_isDeviceHealthy) { + _logger.info("Device not healthy, denying request."); + return false; + } + if (!bypassInteractionCheck && !_canRunGivenUserInteraction()) { + _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."); } return result; } + ComputeRunState get computeState { + return _currentRunState; + } + bool _requestML() { - if (_currentRunState == _ComputeRunState.idle) { - _currentRunState = _ComputeRunState.runningML; + if (_currentRunState == ComputeRunState.idle) { + _currentRunState = ComputeRunState.runningML; _waitingToRunML = false; _logger.info("ML request granted"); return true; - } else if (_currentRunState == _ComputeRunState.runningML) { + } else if (_currentRunState == ComputeRunState.runningML) { return true; } _logger.info( @@ -103,17 +129,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; - } else if (_currentRunState == _ComputeRunState.generatingStream && - !_waitingToRunML) { + _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; } @@ -124,13 +148,13 @@ class ComputeController { ); if (ml) { - if (_currentRunState == _ComputeRunState.runningML) { - _currentRunState = _ComputeRunState.idle; + if (_currentRunState == ComputeRunState.runningML) { + _currentRunState = ComputeRunState.idle; } _waitingToRunML = false; } else if (stream) { - if (_currentRunState == _ComputeRunState.generatingStream) { - _currentRunState = _ComputeRunState.idle; + if (_currentRunState == ComputeRunState.generatingStream) { + _currentRunState = ComputeRunState.idle; } } } @@ -154,12 +178,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)); } diff --git a/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart b/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart index 77fbb309c4..df2f6abec9 100644 --- a/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart +++ b/mobile/apps/photos/lib/services/machine_learning/similar_images_service.dart @@ -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 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 set1, Set set2) { diff --git a/mobile/apps/photos/lib/services/video_preview_service.dart b/mobile/apps/photos/lib/services/video_preview_service.dart index c7d3abd982..4cbdc683fa 100644 --- a/mobile/apps/photos/lib/services/video_preview_service.dart +++ b/mobile/apps/photos/lib/services/video_preview_service.dart @@ -32,6 +32,7 @@ import "package:photos/service_locator.dart"; import "package:photos/services/file_magic_service.dart"; import "package:photos/services/filedata/model/file_data.dart"; import "package:photos/services/isolated_ffmpeg_service.dart"; +import "package:photos/services/machine_learning/compute_controller.dart"; import "package:photos/ui/notification/toast.dart"; import "package:photos/utils/exif_util.dart"; import "package:photos/utils/file_key.dart"; @@ -120,9 +121,14 @@ class VideoPreviewService { if (file.uploadedFileID == null) return false; // Check if already in queue - final bool alreadyInQueue = - await uploadLocksDB.isInStreamQueue(file.uploadedFileID!); + final bool alreadyInQueue = await uploadLocksDB.isInStreamQueue( + 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 } @@ -131,7 +137,7 @@ class VideoPreviewService { // Start processing if not already processing if (uploadingFileId < 0) { - queueFiles(duration: Duration.zero); + queueFiles(duration: Duration.zero, isManual: true); } else { _items[file.uploadedFileID!] = PreviewItem( status: PreviewItemStatus.inQueue, @@ -252,10 +258,12 @@ class VideoPreviewService { BuildContext? ctx, EnteFile enteFile, [ bool forceUpload = false, + bool isManual = false, ]) async { - if (!_allowStream()) { + final canStream = _isPermissionGranted(); + if (!canStream) { _logger.info( - "Pause preview due to disabledSteaming($isVideoStreamingEnabled) or computeController permission)", + "Pause preview due to disabledSteaming($isVideoStreamingEnabled) or computeController permission) - isManual: $isManual", ); if (isVideoStreamingEnabled) _logger.info("No permission to run compute"); clearQueue(); @@ -295,7 +303,8 @@ class VideoPreviewService { "Starting video preview generation for ${enteFile.displayName}", ); // elimination case for <=10 MB with H.264 - var (props, result, file) = await _checkFileForPreviewCreation(enteFile); + var (props, result, file) = + await _checkFileForPreviewCreation(enteFile, isManual); if (result) { removeFile = true; return; @@ -575,8 +584,9 @@ class VideoPreviewService { Future _removeFromLocks(EnteFile enteFile) async { final bool isFailurePresent = _failureFiles?.contains(enteFile.uploadedFileID!) ?? false; - final bool isInManualQueue = - await uploadLocksDB.isInStreamQueue(enteFile.uploadedFileID!); + final bool isInManualQueue = await uploadLocksDB.isInStreamQueue( + enteFile.uploadedFileID!, + ); if (isFailurePresent) { await uploadLocksDB.deleteStreamUploadErrorEntry( @@ -917,27 +927,35 @@ class VideoPreviewService { } Future<(FFProbeProps?, bool, File?)> _checkFileForPreviewCreation( - EnteFile enteFile, - ) async { + EnteFile enteFile, [ + bool isManual = false, + ]) async { if ((enteFile.pubMagicMetadata?.sv ?? 0) == 1) { _logger.info("Skip Preview due to sv=1 for ${enteFile.displayName}"); return (null, true, null); } - if (enteFile.fileSize == null || enteFile.duration == null) { - _logger.warning( - "Skip Preview due to misisng size/duration for ${enteFile.displayName}", - ); - return (null, true, null); - } - final int size = enteFile.fileSize!; - final int duration = enteFile.duration!; - if (size >= 500 * 1024 * 1024 || duration > 60) { - _logger.info("Skip Preview due to size: $size or duration: $duration"); - return (null, true, null); + if (!isManual) { + if (enteFile.fileSize == null || enteFile.duration == null) { + _logger.warning( + "Skip Preview due to misisng size/duration for ${enteFile.displayName}", + ); + return (null, true, null); + } + final int size = enteFile.fileSize!; + final int duration = enteFile.duration!; + if (size >= 500 * 1024 * 1024 || duration > 60) { + _logger.info("Skip Preview due to size: $size or duration: $duration"); + return (null, true, null); + } } FFProbeProps? props; File? file; bool skipFile = false; + if (enteFile.fileSize == null && isManual) { + return (props, skipFile, file); + } + + final size = enteFile.fileSize ?? 0; try { final isFileUnder10MB = size <= 10 * 1024 * 1024; if (isFileUnder10MB) { @@ -1025,8 +1043,9 @@ class VideoPreviewService { } // First try to find the file in the 60-day list - var queueFile = - files.firstWhereOrNull((f) => f.uploadedFileID == queueFileId); + var queueFile = files.firstWhereOrNull( + (f) => f.uploadedFileID == queueFileId, + ); // If not found in 60-day list, fetch it individually queueFile ??= @@ -1124,11 +1143,29 @@ class VideoPreviewService { computeController.requestCompute(stream: true); } - void queueFiles({Duration duration = const Duration(seconds: 5)}) { - Future.delayed(duration, () async { - if (_hasQueuedFile) return; + bool _allowManualStream() { + return isVideoStreamingEnabled && + computeController.requestCompute( + stream: true, + bypassInteractionCheck: true, + bypassMLWaiting: true, + ); + } - final isStreamAllowed = _allowStream(); + bool _isPermissionGranted() { + return isVideoStreamingEnabled && + computeController.computeState == ComputeRunState.generatingStream; + } + + void queueFiles({ + Duration duration = const Duration(seconds: 5), + bool isManual = false, + bool forceProcess = false, + }) { + Future.delayed(duration, () async { + if (_hasQueuedFile && !forceProcess) return; + + final isStreamAllowed = isManual ? _allowManualStream() : _allowStream(); if (!isStreamAllowed) return; await _ensurePreviewIdsInitialized(); diff --git a/mobile/apps/photos/lib/ui/settings/app_icon_selection_screen.dart b/mobile/apps/photos/lib/ui/settings/app_icon_selection_screen.dart index 71000dc1f8..603a74f784 100644 --- a/mobile/apps/photos/lib/ui/settings/app_icon_selection_screen.dart +++ b/mobile/apps/photos/lib/ui/settings/app_icon_selection_screen.dart @@ -13,7 +13,12 @@ enum AppIcon { iconGreen("Default", "IconGreen", "assets/launcher_icon/icon-green.png"), iconLight("Light", "IconLight", "assets/launcher_icon/icon-light.png"), iconDark("Dark", "IconDark", "assets/launcher_icon/icon-dark.png"), - iconOG("OG", "IconOG", "assets/launcher_icon/icon-og.png"); + iconOG("OG", "IconOG", "assets/launcher_icon/icon-og.png"), + iconDuckyHuggingE( + "Ducky", + "IconDuckyHuggingE", + "assets/launcher_icon/icon-ducky-hugging-e.png", + ); final String name; final String id; diff --git a/mobile/apps/photos/lib/ui/settings/backup/free_space_options.dart b/mobile/apps/photos/lib/ui/settings/backup/free_space_options.dart index 0b4446710e..ce2c516a32 100644 --- a/mobile/apps/photos/lib/ui/settings/backup/free_space_options.dart +++ b/mobile/apps/photos/lib/ui/settings/backup/free_space_options.dart @@ -251,9 +251,12 @@ class _FreeUpSpaceOptionsScreenState extends State { ); }, ), - MenuSectionDescriptionWidget( - content: AppLocalizations.of(context) - .viewLargeFilesDesc, + Align( + alignment: Alignment.centerLeft, + child: MenuSectionDescriptionWidget( + content: AppLocalizations.of(context) + .viewLargeFilesDesc, + ), ), const SizedBox( height: 24, diff --git a/mobile/apps/photos/lib/ui/settings/debug/ml_debug_section_widget.dart b/mobile/apps/photos/lib/ui/settings/debug/ml_debug_section_widget.dart index b905228ee8..1d8f45dea6 100644 --- a/mobile/apps/photos/lib/ui/settings/debug/ml_debug_section_widget.dart +++ b/mobile/apps/photos/lib/ui/settings/debug/ml_debug_section_widget.dart @@ -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 { 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( diff --git a/mobile/apps/photos/lib/ui/settings/streaming/video_streaming_settings_page.dart b/mobile/apps/photos/lib/ui/settings/streaming/video_streaming_settings_page.dart index 9c82ec1647..dc8d2a8476 100644 --- a/mobile/apps/photos/lib/ui/settings/streaming/video_streaming_settings_page.dart +++ b/mobile/apps/photos/lib/ui/settings/streaming/video_streaming_settings_page.dart @@ -12,7 +12,6 @@ import "package:photos/theme/ente_theme.dart"; import "package:photos/ui/common/loading_widget.dart"; import "package:photos/ui/common/web_page.dart"; import "package:photos/ui/components/buttons/button_widget.dart"; -import "package:photos/ui/components/buttons/icon_button_widget.dart"; import "package:photos/ui/components/captioned_text_widget.dart"; import "package:photos/ui/components/menu_item_widget/menu_item_widget.dart"; import "package:photos/ui/components/models/button_type.dart"; @@ -49,7 +48,8 @@ class _VideoStreamingSettingsPageState bottomNavigationBar: !hasEnabled ? SafeArea( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 16) + .copyWith(bottom: 20), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -75,15 +75,7 @@ class _VideoStreamingSettingsPageState flexibleSpaceTitle: TitleBarTitleWidget( title: AppLocalizations.of(context).videoStreaming, ), - actionIcons: [ - IconButtonWidget( - icon: Icons.close_outlined, - iconButtonType: IconButtonType.secondary, - onTap: () { - Navigator.popUntil(context, (route) => route.isFirst); - }, - ), - ], + actionIcons: const [], isSliver: false, ), ), @@ -96,17 +88,7 @@ class _VideoStreamingSettingsPageState flexibleSpaceTitle: TitleBarTitleWidget( title: AppLocalizations.of(context).videoStreaming, ), - actionIcons: [ - IconButtonWidget( - icon: Icons.close_outlined, - iconButtonType: IconButtonType.secondary, - onTap: () { - Navigator.pop(context); - if (Navigator.canPop(context)) Navigator.pop(context); - if (Navigator.canPop(context)) Navigator.pop(context); - }, - ), - ], + actionIcons: const [], ), SliverToBoxAdapter( child: Container( @@ -118,7 +100,12 @@ class _VideoStreamingSettingsPageState children: [ TextSpan( text: AppLocalizations.of(context) - .videoStreamingDescription, + .videoStreamingDescriptionLine1, + ), + const TextSpan(text: " "), + TextSpan( + text: AppLocalizations.of(context) + .videoStreamingDescriptionLine2, ), const TextSpan(text: " "), TextSpan( @@ -131,7 +118,6 @@ class _VideoStreamingSettingsPageState ), ], ), - textAlign: TextAlign.justify, style: getEnteTextTheme(context).mini.copyWith( color: getEnteColorScheme(context).textMuted, ), @@ -159,24 +145,34 @@ class _VideoStreamingSettingsPageState height: 160, ), const SizedBox(height: 16), - Text.rich( - TextSpan( - text: AppLocalizations.of(context) - .videoStreamingDescription + - " ", - children: [ - TextSpan( - text: AppLocalizations.of(context).moreDetails, - style: TextStyle( - color: getEnteColorScheme(context).primary500, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text.rich( + TextSpan( + children: [ + TextSpan( + text: AppLocalizations.of(context) + .videoStreamingDescriptionLine1, ), - recognizer: TapGestureRecognizer() - ..onTap = openHelp, - ), - ], + const TextSpan(text: "\n"), + TextSpan( + text: AppLocalizations.of(context) + .videoStreamingDescriptionLine2, + ), + const TextSpan(text: "\n"), + TextSpan( + text: AppLocalizations.of(context).moreDetails, + style: TextStyle( + color: getEnteColorScheme(context).primary500, + ), + recognizer: TapGestureRecognizer() + ..onTap = openHelp, + ), + ], + ), + style: getEnteTextTheme(context).smallMuted, + textAlign: TextAlign.center, ), - style: getEnteTextTheme(context).smallMuted, - textAlign: TextAlign.center, ), const SizedBox(height: 140), ], diff --git a/mobile/apps/photos/lib/ui/tools/similar_images_page.dart b/mobile/apps/photos/lib/ui/tools/similar_images_page.dart index 791518e343..bdd7b8802f 100644 --- a/mobile/apps/photos/lib/ui/tools/similar_images_page.dart +++ b/mobile/apps/photos/lib/ui/tools/similar_images_page.dart @@ -2,6 +2,9 @@ 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'; import "package:photos/core/configuration.dart"; import 'package:photos/core/constants.dart'; @@ -11,9 +14,11 @@ 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'; -import 'package:photos/ui/components/action_sheet_widget.dart'; +import "package:photos/theme/text_style.dart"; import 'package:photos/ui/components/buttons/button_widget.dart'; import "package:photos/ui/components/models/button_type.dart"; import "package:photos/ui/components/toggle_switch_widget.dart"; @@ -23,6 +28,7 @@ import "package:photos/utils/delete_file_util.dart"; import "package:photos/utils/dialog_util.dart"; import "package:photos/utils/navigation_util.dart"; import "package:photos/utils/standalone/data.dart"; +import "package:rive/rive.dart" show RiveAnimation; enum SimilarImagesPageState { setup, @@ -37,6 +43,12 @@ enum SortKey { count, } +enum TabFilter { + same, + close, + related, +} + class SimilarImagesPage extends StatefulWidget { final bool debugScreen; @@ -46,9 +58,12 @@ class SimilarImagesPage extends StatefulWidget { State createState() => _SimilarImagesPageState(); } -class _SimilarImagesPageState extends State { +class _SimilarImagesPageState extends State + with SingleTickerProviderStateMixin { static const crossAxisCount = 3; static const crossAxisSpacing = 12.0; + static const double _closeThreshold = 0.02; + static const double _sameThreshold = 0.001; final _logger = Logger("SimilarImagesPage"); bool _isDisposed = false; @@ -56,19 +71,55 @@ class _SimilarImagesPageState extends State { SimilarImagesPageState _pageState = SimilarImagesPageState.setup; double _distanceThreshold = 0.04; // Default value List _similarFilesList = []; - SortKey _sortKey = SortKey.distanceAsc; + + SortKey _sortKey = SortKey.size; bool _exactSearch = false; bool _fullRefresh = false; - bool _isSelectionSheetOpen = false; + TabFilter _selectedTab = TabFilter.same; late SelectedFiles _selectedFiles; late ValueNotifier _deleteProgress; + late ScrollController _scrollController; + late AnimationController deleteAnimationController; + + List get _filteredGroups { + final filteredGroups = []; + switch (_selectedTab) { + case TabFilter.same: + for (final group in _similarFilesList) { + final distance = group.furthestDistance; + if (distance <= _sameThreshold) { + filteredGroups.add(group); + } + } + case TabFilter.close: + for (final group in _similarFilesList) { + final distance = group.furthestDistance; + if (distance > _sameThreshold && distance <= _closeThreshold) { + filteredGroups.add(group); + } + } + case TabFilter.related: + for (final group in _similarFilesList) { + final distance = group.furthestDistance; + if (distance > _closeThreshold) { + filteredGroups.add(group); + } + } + } + return filteredGroups; + } @override void initState() { super.initState(); _selectedFiles = SelectedFiles(); _deleteProgress = ValueNotifier(""); + _scrollController = ScrollController(); + deleteAnimationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + ); if (!widget.debugScreen) { _findSimilarImages(); @@ -80,6 +131,8 @@ class _SimilarImagesPageState extends State { _isDisposed = true; _selectedFiles.dispose(); _deleteProgress.dispose(); + _scrollController.dispose(); + deleteAnimationController.dispose(); super.dispose(); } @@ -111,52 +164,59 @@ class _SimilarImagesPageState extends State { 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(), ); }, ), @@ -285,7 +345,7 @@ class _SimilarImagesPageState extends State { } Widget _getLoadingView() { - return const SimilarImagesLoadingWidget(); + return const _LoadingScreen(); } Widget _getResultsView() { @@ -318,78 +378,153 @@ class _SimilarImagesPageState extends State { return Column( children: [ + _buildTabBar(), Expanded( - child: ListView.builder( - cacheExtent: 400, - itemCount: _similarFilesList.length + 1, // +1 for header - itemBuilder: (context, index) { - if (index == 0) { - return RepaintBoundary( - child: Container( - margin: const EdgeInsets.symmetric( - horizontal: crossAxisSpacing, - vertical: 12, - ), - padding: const EdgeInsets.all(crossAxisSpacing), - decoration: BoxDecoration( - color: colorScheme.fillFaint, - borderRadius: BorderRadius.circular(8), - ), - child: Row( + child: _filteredGroups.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.photo_library_outlined, - size: 20, - color: colorScheme.textMuted, + SvgPicture.asset( + "assets/ducky_cleaning_static.svg", + height: 160, ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of(context).similarGroupsFound( - count: _similarFilesList.length, - ), - style: textTheme.bodyBold, - ), - const SizedBox(height: 4), - Text( - AppLocalizations.of(context) - .reviewAndRemoveSimilarImages, - style: textTheme.miniMuted, - ), - ], - ), + const SizedBox(height: 16), + Text( + AppLocalizations.of(context).nothingToTidyUpHere, + textAlign: TextAlign.center, + style: textTheme.bodyMuted, ), + const SizedBox(height: 48), ], ), ), - ); - } - - // Similar files groups (index - 1 because first item is header) - final similarFiles = _similarFilesList[index - 1]; - return RepaintBoundary( - child: _buildSimilarFilesGroup(similarFiles), - ); - }, - ), + ) + : ListView.builder( + controller: _scrollController, + cacheExtent: 400, + itemCount: _filteredGroups.length, + itemBuilder: (context, index) { + final similarFiles = _filteredGroups[index]; + return Column( + children: [ + if (index == 0) const SizedBox(height: 16), + RepaintBoundary( + child: _buildSimilarFilesGroup(similarFiles), + ), + ], + ); + }, + ), ), - _getBottomActionButtons(), + if (_filteredGroups.isNotEmpty) _getBottomActionButtons(), ], ); } + Widget _buildTabBar() { + final colorScheme = getEnteColorScheme(context); + final textTheme = getEnteTextTheme(context); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + _buildTabButton( + TabFilter.same, + AppLocalizations.of(context).same, + colorScheme, + textTheme, + ), + const SizedBox(width: crossAxisSpacing), + _buildTabButton( + TabFilter.close, + AppLocalizations.of(context).close, + colorScheme, + textTheme, + ), + const SizedBox(width: crossAxisSpacing), + _buildTabButton( + TabFilter.related, + AppLocalizations.of(context).related, + colorScheme, + textTheme, + ), + ], + ), + ); + } + + Widget _buildTabButton( + TabFilter tab, + String label, + EnteColorScheme colorScheme, + EnteTextTheme textTheme, + ) { + final isSelected = _selectedTab == tab; + + return GestureDetector( + onTap: () => _onTabChanged(tab), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: BoxDecoration( + color: isSelected ? colorScheme.primary700 : colorScheme.fillFaint, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + label, + style: isSelected + ? textTheme.smallBold.copyWith(color: Colors.white) + : textTheme.smallBold, + ), + ), + ); + } + + void _onTabChanged(TabFilter newTab) { + setState(() { + _selectedTab = newTab; + + final newSelection = {}; + for (final group in _filteredGroups) { + for (int i = 1; i < group.files.length; i++) { + final file = group.files[i]; + if (FavoritesService.instance.isFavoriteCache(file)) continue; + newSelection.add(file); + } + } + _selectedFiles.clearAll(); + _selectedFiles.selectAll(newSelection); + }); + } + Widget _getBottomActionButtons() { return ListenableBuilder( listenable: _selectedFiles, builder: (context, _) { - final selectedCount = _selectedFiles.files.length; - final hasSelectedFiles = selectedCount > 0; + final eligibleFilteredFiles = {}; + int autoSelectCount = 0; + for (final group in _filteredGroups) { + 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 >= autoSelectCount; + final hasSelectedFiles = selectedFilteredFiles.isNotEmpty; int totalSize = 0; - for (final file in _selectedFiles.files) { + for (final file in selectedFilteredFiles) { totalSize += file.fileSize ?? 0; } @@ -424,7 +559,7 @@ class _SimilarImagesPageState extends State { ), ); }, - child: hasSelectedFiles && !_isSelectionSheetOpen + child: hasSelectedFiles ? Column( key: const ValueKey('delete_section'), children: [ @@ -433,7 +568,8 @@ class _SimilarImagesPageState extends State { child: ButtonWidget( labelText: AppLocalizations.of(context) .deletePhotosWithSize( - count: selectedCount, + count: NumberFormat() + .format(selectedFilteredFiles.length), size: formatBytes(totalSize), ), buttonType: ButtonType.critical, @@ -441,9 +577,10 @@ class _SimilarImagesPageState extends State { shouldShowSuccessConfirmation: false, onTap: () async { await _deleteFiles( - _selectedFiles.files, + selectedFilteredFiles, showDialog: true, showUIFeedback: true, + scrollToTop: true, ); }, ), @@ -453,27 +590,20 @@ class _SimilarImagesPageState extends State { ) : const SizedBox.shrink(key: ValueKey('no_delete')), ), - if (!_isSelectionSheetOpen) - SizedBox( - width: double.infinity, - child: ButtonWidget( - labelText: AppLocalizations.of(context).selectionOptions, - buttonType: ButtonType.secondary, - shouldSurfaceExecutionStates: false, - shouldShowSuccessConfirmation: false, - onTap: () async { - setState(() { - _isSelectionSheetOpen = true; - }); - await _showSelectionOptionsSheet(); - if (mounted) { - setState(() { - _isSelectionSheetOpen = false; - }); - } - }, - ), + SizedBox( + width: double.infinity, + child: ButtonWidget( + labelText: allFilteredSelected + ? AppLocalizations.of(context).unselectAll + : AppLocalizations.of(context).selectAll, + buttonType: ButtonType.secondary, + shouldSurfaceExecutionStates: false, + shouldShowSuccessConfirmation: false, + onTap: () async { + _toggleSelectAll(allFilteredSelected); + }, ), + ), ], ), ), @@ -482,6 +612,23 @@ class _SimilarImagesPageState extends State { ); } + void _toggleSelectAll(bool allSelected) { + final autoSelectFiles = {}; + for (final group in _filteredGroups) { + for (int i = 1; i < group.files.length; i++) { + final file = group.files[i]; + if (FavoritesService.instance.isFavoriteCache(file)) continue; + autoSelectFiles.add(file); + } + } + + if (allSelected) { + _selectedFiles.clearAll(); + } else { + _selectedFiles.selectAll(autoSelectFiles); + } + } + Future _findSimilarImages() async { if (_isDisposed) return; setState(() { @@ -489,7 +636,6 @@ class _SimilarImagesPageState extends State { }); try { - // You can use _toggleValue here for advanced mode features _logger.info("exact mode: $_exactSearch"); final similarFiles = await SimilarImagesService.instance.getSimilarFiles( @@ -505,6 +651,16 @@ class _SimilarImagesPageState extends State { _pageState = SimilarImagesPageState.results; _sortSimilarFiles(); + for (final group in _similarFilesList) { + if (group.files.length > 1) { + for (int i = 1; i < group.files.length; i++) { + final file = group.files[i]; + if (FavoritesService.instance.isFavoriteCache(file)) continue; + _selectedFiles.toggleSelection(file); + } + } + } + if (_isDisposed) return; setState(() {}); @@ -557,114 +713,6 @@ class _SimilarImagesPageState extends State { setState(() {}); } - void _selectFilesByThreshold(double threshold) { - final filesToSelect = {}; - - for (final similarFilesGroup in _similarFilesList) { - if (similarFilesGroup.furthestDistance <= threshold) { - for (int i = 1; i < similarFilesGroup.files.length; i++) { - filesToSelect.add(similarFilesGroup.files[i]); - } - } - } - - if (filesToSelect.isNotEmpty) { - _selectedFiles.clearAll(fireEvent: false); - _selectedFiles.selectAll(filesToSelect); - } else { - _selectedFiles.clearAll(fireEvent: false); - } - } - - Future _showSelectionOptionsSheet() async { - // Calculate how many files fall into each category - int exactFiles = 0; - int similarFiles = 0; - int allFiles = 0; - - for (final group in _similarFilesList) { - final duplicateCount = group.files.length - 1; // Exclude the first file - allFiles += duplicateCount; - - if (group.furthestDistance <= 0.0) { - exactFiles += duplicateCount; - similarFiles += duplicateCount; - } else if (group.furthestDistance <= 0.02) { - similarFiles += duplicateCount; - } - } - - // Always show counts, even when 0 - final String exactLabel = - AppLocalizations.of(context).selectExactWithCount(count: exactFiles); - - final String similarLabel = AppLocalizations.of(context) - .selectSimilarWithCount(count: similarFiles); - - final String allLabel = - AppLocalizations.of(context).selectAllWithCount(count: allFiles); - - await showActionSheet( - context: context, - title: AppLocalizations.of(context).selectSimilarImagesTitle, - body: AppLocalizations.of(context).chooseSimilarImagesToSelect, - buttons: [ - ButtonWidget( - labelText: exactLabel, - buttonType: ButtonType.neutral, - buttonSize: ButtonSize.large, - shouldStickToDarkTheme: true, - isInAlert: true, - buttonAction: ButtonAction.first, - shouldSurfaceExecutionStates: false, - isDisabled: exactFiles == 0, - onTap: () async { - _selectFilesByThreshold(0.0); - }, - ), - ButtonWidget( - labelText: similarLabel, - buttonType: ButtonType.neutral, - buttonSize: ButtonSize.large, - shouldStickToDarkTheme: true, - isInAlert: true, - buttonAction: ButtonAction.second, - shouldSurfaceExecutionStates: false, - isDisabled: similarFiles == 0, - onTap: () async { - _selectFilesByThreshold(0.02); - }, - ), - ButtonWidget( - labelText: allLabel, - buttonType: ButtonType.neutral, - buttonSize: ButtonSize.large, - shouldStickToDarkTheme: true, - isInAlert: true, - buttonAction: ButtonAction.third, - shouldSurfaceExecutionStates: false, - isDisabled: allFiles == 0, - onTap: () async { - _selectFilesByThreshold(0.05); - }, - ), - ButtonWidget( - labelText: AppLocalizations.of(context).clearSelection, - buttonType: ButtonType.secondary, - buttonSize: ButtonSize.large, - shouldStickToDarkTheme: true, - isInAlert: true, - buttonAction: ButtonAction.cancel, - shouldSurfaceExecutionStates: false, - onTap: () async { - _selectedFiles.clearAll(fireEvent: false); - }, - ), - ], - actionSheetType: ActionSheetType.defaultActionSheet, - ); - } - Widget _buildSimilarFilesGroup(SimilarFiles similarFiles) { final textTheme = getEnteTextTheme(context); return Padding( @@ -893,6 +941,7 @@ class _SimilarImagesPageState extends State { Set filesToDelete, { bool showDialog = true, bool showUIFeedback = true, + bool scrollToTop = false, }) async { if (filesToDelete.isEmpty) return; if (showDialog) { @@ -908,6 +957,7 @@ class _SimilarImagesPageState extends State { filesToDelete, true, showUIFeedback: showUIFeedback, + scrollToTop: scrollToTop, ); } catch (e, s) { _logger.severe("Failed to delete files", e, s); @@ -922,6 +972,7 @@ class _SimilarImagesPageState extends State { filesToDelete, true, showUIFeedback: showUIFeedback, + scrollToTop: scrollToTop, ); } } @@ -930,6 +981,7 @@ class _SimilarImagesPageState extends State { Set filesToDelete, bool createSymlink, { bool showUIFeedback = true, + bool scrollToTop = false, }) async { if (filesToDelete.isEmpty) { return; @@ -975,15 +1027,15 @@ class _SimilarImagesPageState extends State { _similarFilesList.remove(group); } + final int collectionCnt = collectionToFilesToAddMap.keys.length; if (createSymlink) { final userID = Configuration.instance.getUserID(); - final int collectionCnt = collectionToFilesToAddMap.keys.length; int progress = 0; for (final collectionID in collectionToFilesToAddMap.keys) { if (!mounted) { return; } - if (collectionCnt > 0 && showUIFeedback) { + if (collectionCnt > 2 && showUIFeedback) { progress++; // calculate progress percentage upto 2 decimal places final double percentage = (progress / collectionCnt) * 100; @@ -1004,7 +1056,7 @@ class _SimilarImagesPageState extends State { } } } - if (showUIFeedback) { + if (collectionCnt > 2 && showUIFeedback) { _deleteProgress.value = ""; } @@ -1012,19 +1064,29 @@ class _SimilarImagesPageState extends State { 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.isNotEmpty && mounted && showUIFeedback) { + if (allDeleteFiles.length > 100 && mounted && showUIFeedback) { final int totalSize = allDeleteFiles.fold( 0, (sum, file) => sum + (file.fileSize ?? 0), ); - _showCongratulationsDialog(allDeleteFiles.length, totalSize); + _showCongratulationsDialog(totalSize); } } - void _showCongratulationsDialog(int deletedCount, int totalSize) { + void _showCongratulationsDialog(int totalSize) { final textTheme = getEnteTextTheme(context); final colorScheme = getEnteColorScheme(context); + final screenWidth = MediaQuery.of(context).size.width; showDialog( context: context, @@ -1034,39 +1096,43 @@ class _SimilarImagesPageState extends State { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.celebration_outlined, - size: 48, - color: colorScheme.primary500, - ), - const SizedBox(height: 16), - Text( - AppLocalizations.of(context).greatJob, - style: textTheme.h3Bold, - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Text( - AppLocalizations.of(context).cleanedUpSimilarImages( - count: deletedCount, - size: formatBytes(totalSize), + contentPadding: const EdgeInsets.all(24), + content: SizedBox( + width: screenWidth - (crossAxisSpacing), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SvgPicture.asset( + "assets/ducky_cleaning_static.svg", + height: 160, ), - style: textTheme.body, - textAlign: TextAlign.center, - ), - const SizedBox(height: 24), - SizedBox( - width: double.infinity, - child: ButtonWidget( - labelText: AppLocalizations.of(context).done, - buttonType: ButtonType.primary, - onTap: () async => Navigator.of(context).pop(), + const SizedBox(height: 16), + Text( + AppLocalizations.of(context).hoorayyyy, + style: textTheme.h2Bold.copyWith( + color: colorScheme.primary500, + ), + textAlign: TextAlign.center, ), - ), - ], + const SizedBox(height: 8), + Text( + AppLocalizations.of(context).cleanedUpSimilarImages( + size: formatBytes(totalSize), + ), + style: textTheme.body, + textAlign: TextAlign.center, + ), + const SizedBox(height: 36), + SizedBox( + width: double.infinity, + child: ButtonWidget( + labelText: AppLocalizations.of(context).done, + buttonType: ButtonType.primary, + onTap: () async => Navigator.of(context).pop(), + ), + ), + ], + ), ), ), ); @@ -1080,7 +1146,7 @@ class _SimilarImagesPageState extends State { String text; switch (key) { case SortKey.size: - text = AppLocalizations.of(context).size; + text = AppLocalizations.of(context).totalSize; break; case SortKey.distanceAsc: text = AppLocalizations.of(context).similarity; @@ -1141,242 +1207,80 @@ class _SimilarImagesPageState extends State { } } -class SimilarImagesLoadingWidget extends StatefulWidget { - const SimilarImagesLoadingWidget({super.key}); +class _LoadingScreen extends StatefulWidget { + const _LoadingScreen(); @override - State createState() => - _SimilarImagesLoadingWidgetState(); + State<_LoadingScreen> createState() => _LoadingScreenState(); } -class _SimilarImagesLoadingWidgetState extends State - with TickerProviderStateMixin { - late AnimationController _loadingAnimationController; - late AnimationController _pulseAnimationController; - late Animation _scaleAnimation; - late Animation _pulseAnimation; - int _loadingMessageIndex = 0; +class _LoadingScreenState extends State<_LoadingScreen> { + Timer? _timer; + int _currentTextIndex = 0; - List get _loadingMessages => [ - AppLocalizations.of(context).analyzingPhotosLocally, - AppLocalizations.of(context).lookingForVisualSimilarities, - AppLocalizations.of(context).comparingImageDetails, - AppLocalizations.of(context).findingSimilarImages, - AppLocalizations.of(context).almostDone, - ]; + late List _loadingTexts; @override void initState() { super.initState(); - - // Initialize loading animations - _loadingAnimationController = AnimationController( - duration: const Duration(seconds: 2), - vsync: this, - )..repeat(); - - _pulseAnimationController = AnimationController( - duration: const Duration(milliseconds: 1500), - vsync: this, - )..repeat(reverse: true); - - _scaleAnimation = Tween( - begin: 0.8, - end: 1.0, - ).animate( - CurvedAnimation( - parent: _pulseAnimationController, - curve: Curves.easeInOut, - ), - ); - - _pulseAnimation = Tween( - begin: 0.4, - end: 1.0, - ).animate( - CurvedAnimation( - parent: _pulseAnimationController, - curve: Curves.easeInOut, - ), - ); - - _startMessageCycling(); + _startTextCycling(); } - void _startMessageCycling() { - Future.doWhile(() async { - if (!mounted) return false; - await Future.delayed(const Duration(seconds: 7)); - if (mounted) { - setState(() { - _loadingMessageIndex++; - }); - // Stop cycling after reaching the last message - return _loadingMessageIndex < _loadingMessages.length - 1; + 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(); + } } - return false; }); } @override void dispose() { - _loadingAnimationController.dispose(); - _pulseAnimationController.dispose(); + _timer?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { final textTheme = getEnteTextTheme(context); - final colorScheme = getEnteColorScheme(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: [ - // Animated scanning effect - Stack( - alignment: Alignment.center, - children: [ - // Pulsing background circle - AnimatedBuilder( - animation: _pulseAnimation, - builder: (context, child) { - return Container( - width: 160, - height: 160, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: colorScheme.primary500.withValues( - alpha: _pulseAnimation.value * 0.1, - ), - ), - ); - }, - ), - // Rotating scanner ring - AnimatedBuilder( - animation: _loadingAnimationController, - builder: (context, child) { - return Transform.rotate( - angle: _loadingAnimationController.value * 2 * 3.14159, - child: Container( - width: 120, - height: 120, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: colorScheme.primary500, - width: 2, - ), - gradient: SweepGradient( - colors: [ - colorScheme.primary500.withValues(alpha: 0), - colorScheme.primary500.withValues(alpha: 0.3), - colorScheme.primary500.withValues(alpha: 0.6), - colorScheme.primary500, - colorScheme.primary500.withValues(alpha: 0), - ], - stops: const [0.0, 0.25, 0.5, 0.75, 1.0], - ), - ), - ), - ); - }, - ), - // Center icon with scale animation - AnimatedBuilder( - animation: _scaleAnimation, - builder: (context, child) { - return Transform.scale( - scale: _scaleAnimation.value, - child: Container( - width: 80, - height: 80, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: colorScheme.backgroundElevated, - boxShadow: [ - BoxShadow( - color: colorScheme.strokeFaint, - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: Icon( - Icons.photo_library_outlined, - size: 40, - color: colorScheme.primary500, - ), - ), - ); - }, - ), - ], - ), - const SizedBox(height: 48), - // Privacy badge - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: colorScheme.fillFaint, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.lock_outline, - size: 14, - color: colorScheme.textMuted, - ), - const SizedBox(width: 6), - Text( - AppLocalizations.of(context).processingLocally, - style: textTheme.miniFaint, - ), - ], + const SizedBox( + height: 160, + child: RiveAnimation.asset( + 'assets/ducky_analyze_files.riv', + fit: BoxFit.contain, ), ), const SizedBox(height: 16), - // Animated loading message AnimatedSwitcher( duration: const Duration(milliseconds: 500), child: Text( - _loadingMessages[_loadingMessageIndex], - key: ValueKey(_loadingMessageIndex), - style: textTheme.body, + _loadingTexts[_currentTextIndex], + key: ValueKey(_currentTextIndex), + style: textTheme.bodyMuted, textAlign: TextAlign.center, ), ), - const SizedBox(height: 8), - // Progress dots - Row( - mainAxisSize: MainAxisSize.min, - children: List.generate( - 3, - (index) => AnimatedBuilder( - animation: _loadingAnimationController, - builder: (context, child) { - final delay = index * 0.2; - final value = - (_loadingAnimationController.value + delay) % 1.0; - return Container( - margin: const EdgeInsets.symmetric(horizontal: 4), - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: colorScheme.primary500.withValues( - alpha: value < 0.5 ? value * 2 : 2 - value * 2, - ), - ), - ); - }, - ), - ), - ), ], ), ); diff --git a/mobile/apps/photos/lib/ui/viewer/file/file_app_bar.dart b/mobile/apps/photos/lib/ui/viewer/file/file_app_bar.dart index 27cbe4e9f4..3701424e27 100644 --- a/mobile/apps/photos/lib/ui/viewer/file/file_app_bar.dart +++ b/mobile/apps/photos/lib/ui/viewer/file/file_app_bar.dart @@ -497,6 +497,7 @@ class FileAppBarState extends State { final userId = Configuration.instance.getUserID(); return widget.file.fileType == FileType.video && widget.file.isUploaded && + widget.file.fileSize != null && (widget.file.pubMagicMetadata?.sv ?? 0) != 1 && widget.file.ownerID == userId; } diff --git a/mobile/apps/photos/lib/ui/viewer/file/thumbnail_widget.dart b/mobile/apps/photos/lib/ui/viewer/file/thumbnail_widget.dart index cbc707d6d4..5b02e15e3a 100644 --- a/mobile/apps/photos/lib/ui/viewer/file/thumbnail_widget.dart +++ b/mobile/apps/photos/lib/ui/viewer/file/thumbnail_widget.dart @@ -7,23 +7,24 @@ import 'package:logging/logging.dart'; 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/exceptions.dart'; import 'package:photos/core/event_bus.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/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 { @@ -307,8 +308,13 @@ class _ThumbnailWidgetState extends State { 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"); + } else { + _logger.warning("Could not load thumbnail from disk: ", e); + } }); } @@ -326,7 +332,7 @@ class _ThumbnailWidgetState extends State { } //Do not retry if the widget is not mounted if (!mounted) { - return null; + throw WidgetUnmountedException("Thumbnail loading cancelled: widget unmounted"); } retryAttempts++; diff --git a/mobile/apps/photos/lib/ui/viewer/gallery/gallery.dart b/mobile/apps/photos/lib/ui/viewer/gallery/gallery.dart index e1a9a1c188..bb537972ba 100644 --- a/mobile/apps/photos/lib/ui/viewer/gallery/gallery.dart +++ b/mobile/apps/photos/lib/ui/viewer/gallery/gallery.dart @@ -604,7 +604,6 @@ class GalleryState extends State { ? const NeverScrollableScrollPhysics() : const ExponentialBouncingScrollPhysics(), controller: _scrollController, - cacheExtent: galleryCacheExtent, slivers: [ SliverToBoxAdapter( child: SizeChangedLayoutNotifier( @@ -642,25 +641,6 @@ class GalleryState extends State { ), ); } - - 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 { diff --git a/mobile/apps/photos/lib/utils/bg_task_utils.dart b/mobile/apps/photos/lib/utils/bg_task_utils.dart index 1ae1dfcb0b..bca4d2f700 100644 --- a/mobile/apps/photos/lib/utils/bg_task_utils.dart +++ b/mobile/apps/photos/lib/utils/bg_task_utils.dart @@ -6,7 +6,6 @@ import "package:permission_handler/permission_handler.dart"; import "package:photos/db/upload_locks_db.dart"; import "package:photos/extensions/stop_watch.dart"; import "package:photos/main.dart"; -import "package:photos/service_locator.dart"; import "package:photos/utils/file_uploader.dart"; import "package:shared_preferences/shared_preferences.dart"; import "package:workmanager/workmanager.dart" as workmanager; @@ -81,7 +80,7 @@ class BgTaskUtils { try { await workmanager.Workmanager().initialize( callbackDispatcher, - isInDebugMode: Platform.isIOS && flagService.internalUser, + isInDebugMode: false, ); await workmanager.Workmanager().registerPeriodicTask( backgroundTaskIdentifier, diff --git a/mobile/apps/photos/pubspec.lock b/mobile/apps/photos/pubspec.lock index ce3162ad2b..0c11e8576a 100644 --- a/mobile/apps/photos/pubspec.lock +++ b/mobile/apps/photos/pubspec.lock @@ -2165,6 +2165,22 @@ packages: url: "https://github.com/KasemJaffer/receive_sharing_intent.git" source: git version: "1.8.1" + rive: + dependency: "direct main" + description: + name: rive + sha256: "2551a44fa766a7ed3f52aa2b94feda6d18d00edc25dee5f66e72e9b365bb6d6c" + url: "https://pub.dev" + source: hosted + version: "0.13.20" + rive_common: + dependency: transitive + description: + name: rive_common + sha256: "2ba42f80d37a4efd0696fb715787c4785f8a13361e8aea9227c50f1e78cf763a" + url: "https://pub.dev" + source: hosted + version: "0.4.15" rust_lib_photos: dependency: "direct main" description: diff --git a/mobile/apps/photos/pubspec.yaml b/mobile/apps/photos/pubspec.yaml index 13fdd3a965..0d45cceb7d 100644 --- a/mobile/apps/photos/pubspec.yaml +++ b/mobile/apps/photos/pubspec.yaml @@ -179,6 +179,7 @@ dependencies: git: url: https://github.com/KasemJaffer/receive_sharing_intent.git ref: 2cea396 + rive: ^0.13.20 rust_lib_photos: path: rust_builder screenshot: ^3.0.0 diff --git a/mobile/apps/photos/rust/src/api/usearch_api.rs b/mobile/apps/photos/rust/src/api/usearch_api.rs index 847e86d665..395f93b04d 100644 --- a/mobile/apps/photos/rust/src/api/usearch_api.rs +++ b/mobile/apps/photos/rust/src/api/usearch_api.rs @@ -32,7 +32,7 @@ impl VectorDB { if file_exists { println!("Loading index from disk."); - // Creates a view of the index from a file without loading it into memory. https://docs.rs/usearch/latest/usearch/struct.Index.html#method.view + // 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"); } else { println!("Creating new index."); diff --git a/mobile/apps/photos/scripts/internal_changes.txt b/mobile/apps/photos/scripts/internal_changes.txt index 286f24adeb..8354850bb0 100644 --- a/mobile/apps/photos/scripts/internal_changes.txt +++ b/mobile/apps/photos/scripts/internal_changes.txt @@ -1,3 +1,10 @@ +- Ashil: Revert diskLoadDeferDuration to 500ms +- 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 +- Prateek: Fix multiple concurrent streaming processes bug in ComputeController +- Prateek: Fix video streaming description text display spacing in advanced settings - Ashil: Render cached thumbnails faster (noticeable in gallery scrolling) - Similar images UI changes - Neeraj: Fix for double enteries for local file diff --git a/mobile/apps/photos/scripts/store_changes.txt b/mobile/apps/photos/scripts/store_changes.txt index f1baf26cae..d7bc0e0206 100644 --- a/mobile/apps/photos/scripts/store_changes.txt +++ b/mobile/apps/photos/scripts/store_changes.txt @@ -1,3 +1,4 @@ +- Video streaming improvements - Added support for custom domain links - Image editor fixes: - Fixed bottom navigation bar color in light theme diff --git a/mobile/packages/network/lib/network.dart b/mobile/packages/network/lib/network.dart index b95c4b5a8f..d36a3e721b 100644 --- a/mobile/packages/network/lib/network.dart +++ b/mobile/packages/network/lib/network.dart @@ -17,18 +17,22 @@ class Network { late Dio _enteDio; Future 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, diff --git a/web/packages/base/locales/lt-LT/translation.json b/web/packages/base/locales/lt-LT/translation.json index 91d3494e06..6678cf69ea 100644 --- a/web/packages/base/locales/lt-LT/translation.json +++ b/web/packages/base/locales/lt-LT/translation.json @@ -685,7 +685,7 @@ "person_favorites": "{{name}} mėgstami", "shared_favorites": "Bendrinami mėgstami", "added_by_name": "Įtraukė {{name}}", - "unowned_files_not_processed": "Kiti naudotojai pridėti failai nebuvo apdoroti", + "unowned_files_not_processed": "Kitų naudotojų įtraukti failai nebuvo apdoroti", "custom_domains": "Pasirinktiniai domenai", "custom_domains_desc": "Naudokite savo domeną, kai bendrinate.", "link_your_domain": "Susieti savo domeną",